diff --git a/desktop/src/features/channels/ui/useChannelIntro.tsx b/desktop/src/features/channels/ui/useChannelIntro.tsx index 910d836ff08..3374fb7a654 100644 --- a/desktop/src/features/channels/ui/useChannelIntro.tsx +++ b/desktop/src/features/channels/ui/useChannelIntro.tsx @@ -9,6 +9,8 @@ import { isWelcomeChannel, isWelcomeExperienceChannel, } from "@/features/onboarding/welcome"; +import { useIsProjectHomeChannel } from "@/features/projects/lib/projectHomeChannel"; +import { ProjectChannelIcon } from "@/features/projects/ui/ProjectChannelIcon"; import type { Channel } from "@/shared/api/types"; import { HashSearch } from "@/shared/ui/icons"; @@ -43,6 +45,8 @@ export function useChannelIntro({ onOpenMembers?: () => void; onWelcomeAddAgent?: () => void; }) { + const projectHome = useIsProjectHomeChannel(activeChannel?.id); + return React.useMemo(() => { if (!activeChannel || activeChannel.channelType === "dm") { return null; @@ -81,7 +85,7 @@ export function useChannelIntro({ actions, channelKindLabel: isWelcomeChannel(activeChannel) ? "private welcome channel" - : getChannelIntroKind(activeChannel), + : getChannelIntroKind(activeChannel, projectHome), channelName: activeChannel.name, description: isWelcomeChannel(activeChannel) ? null @@ -103,9 +107,9 @@ export function useChannelIntro({ if (onAddAgent) { actions.push({ - description: "Bring them in.", - icon: , - label: "Add agents", + description: "Add an agent here.", + icon: , + label: "Add agent", onClick: onAddAgent, testId: "channel-intro-action-create-agent", }); @@ -114,7 +118,7 @@ export function useChannelIntro({ if (onOpenMembers) { actions.push({ description: "Invite members.", - icon: , + icon: , label: "Add people", onClick: onOpenMembers, testId: "channel-intro-action-add-people", @@ -124,9 +128,13 @@ export function useChannelIntro({ return { actions, - channelKindLabel: getChannelIntroKind(activeChannel), + channelKindLabel: getChannelIntroKind(activeChannel, projectHome), channelName: activeChannel.name, description: getChannelIntroDescription(activeChannel), + hideBeginning: projectHome, + icon: projectHome ? ( + + ) : undefined, }; }, [ activeChannel, @@ -136,5 +144,6 @@ export function useChannelIntro({ onCreateChannel, onOpenMembers, onWelcomeAddAgent, + projectHome, ]); } diff --git a/desktop/src/features/messages/ui/ChannelIntroBlock.tsx b/desktop/src/features/messages/ui/ChannelIntroBlock.tsx index c69fd46a32d..fc4379f8b7f 100644 --- a/desktop/src/features/messages/ui/ChannelIntroBlock.tsx +++ b/desktop/src/features/messages/ui/ChannelIntroBlock.tsx @@ -16,6 +16,7 @@ export type ChannelIntro = { channelKindLabel: string; channelName: string; description?: string | null; + hideBeginning?: boolean; icon?: React.ReactNode; }; @@ -50,13 +51,15 @@ export function ChannelIntroBlock({

#{intro.channelName}

-

- This is the beginning of the{" "} - - {intro.channelKindLabel} - - . -

+ {intro.hideBeginning ? null : ( +

+ This is the beginning of the{" "} + + {intro.channelKindLabel} + + . +

+ )} {intro.description ? (

{intro.description} diff --git a/desktop/src/features/projects/lib/projectAgentSelection.test.mjs b/desktop/src/features/projects/lib/projectAgentSelection.test.mjs new file mode 100644 index 00000000000..2c59d6351b9 --- /dev/null +++ b/desktop/src/features/projects/lib/projectAgentSelection.test.mjs @@ -0,0 +1,21 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { pickDefaultProjectsAgent } from "./projectAgentSelection.ts"; + +test("prefers Fizz over the first running agent", () => { + const implementationPartner = { + name: "Implementation Partner", + personaId: "custom:implementation", + }; + const fizz = { name: "Fizz", personaId: "builtin:fizz" }; + assert.equal(pickDefaultProjectsAgent([implementationPartner, fizz]), fizz); +}); + +test("keeps legacy Kit compatibility and falls back to first", () => { + const first = { name: "Builder" }; + const kit = { name: "Kit" }; + assert.equal(pickDefaultProjectsAgent([first, kit]), kit); + assert.equal(pickDefaultProjectsAgent([first]), first); + assert.equal(pickDefaultProjectsAgent([]), null); +}); diff --git a/desktop/src/features/projects/lib/projectAgentSelection.ts b/desktop/src/features/projects/lib/projectAgentSelection.ts new file mode 100644 index 00000000000..6aa43392ee9 --- /dev/null +++ b/desktop/src/features/projects/lib/projectAgentSelection.ts @@ -0,0 +1,16 @@ +const WELCOME_GUIDE_PERSONA_ID = "builtin:fizz"; + +/** Prefers the built-in welcome lead for a new Projects conversation. */ +export function pickDefaultProjectsAgent< + Agent extends { name: string; personaId?: string | null }, +>(agents: readonly Agent[]): Agent | null { + return ( + agents.find((agent) => agent.personaId === WELCOME_GUIDE_PERSONA_ID) ?? + agents.find((agent) => { + const name = agent.name.trim().toLocaleLowerCase(); + return name === "fizz" || name === "kit"; + }) ?? + agents[0] ?? + null + ); +} diff --git a/desktop/src/features/projects/lib/projectRelatedChannels.test.mjs b/desktop/src/features/projects/lib/projectRelatedChannels.test.mjs index ad1795ad735..e6fc3196f20 100644 --- a/desktop/src/features/projects/lib/projectRelatedChannels.test.mjs +++ b/desktop/src/features/projects/lib/projectRelatedChannels.test.mjs @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import { test } from "node:test"; import { + collapseProjectRelatedChannelRows, collectProjectRelatedChannelRows, listProjectBoundChannels, listProjectChildChannels, @@ -106,6 +107,37 @@ test("collects one row per repository channel binding", () => { ); }); +test("collapses repositories sharing one project channel", () => { + const rows = collectProjectRelatedChannelRows([ + makeProject({ + repositories: [ + makeRepository({ name: "web" }), + makeRepository({ id: "repo-mobile", name: "mobile" }), + makeRepository({ + channelId: CHANNEL_B, + id: "repo-relay", + name: "relay", + }), + ], + }), + ]); + + assert.deepEqual(collapseProjectRelatedChannelRows(rows), [ + { + channelId: CHANNEL_A, + projectId: "project-buzz", + projectName: "buzz", + repositoryNames: ["web", "mobile"], + }, + { + channelId: CHANNEL_B, + projectId: "project-buzz", + projectName: "buzz", + repositoryNames: ["relay"], + }, + ]); +}); + test("keeps a project channel only when no repository in that project shares it", () => { assert.deepEqual( collectProjectRelatedChannelRows([ diff --git a/desktop/src/features/projects/lib/projectRelatedChannels.ts b/desktop/src/features/projects/lib/projectRelatedChannels.ts index 525eb700c26..d5166a48536 100644 --- a/desktop/src/features/projects/lib/projectRelatedChannels.ts +++ b/desktop/src/features/projects/lib/projectRelatedChannels.ts @@ -20,6 +20,14 @@ export type ProjectRelatedChannelRow = { repositoryName: string | null; }; +/** One display row per distinct channel within a project. */ +export type ProjectRelatedChannelDisplayRow = { + channelId: string; + projectId: string; + projectName: string; + repositoryNames: string[]; +}; + function trimmedChannelId(value: string | null | undefined) { const channelId = value?.trim() ?? ""; return channelId.length > 0 ? channelId : null; @@ -88,6 +96,40 @@ export function projectRelatedChannelRowKey(row: ProjectRelatedChannelRow) { return `${row.channelId}:${row.projectId}:${row.repositoryId ?? "project"}`; } +/** Collapses repository bindings that point at the same project channel. */ +export function collapseProjectRelatedChannelRows( + rows: readonly ProjectRelatedChannelRow[], +): ProjectRelatedChannelDisplayRow[] { + const collapsed = new Map(); + for (const row of rows) { + const key = `${row.projectId}:${row.channelId}`; + const current = collapsed.get(key); + if (current) { + if ( + row.repositoryName && + !current.repositoryNames.includes(row.repositoryName) + ) { + current.repositoryNames.push(row.repositoryName); + } + continue; + } + collapsed.set(key, { + channelId: row.channelId, + projectId: row.projectId, + projectName: row.projectName, + repositoryNames: row.repositoryName ? [row.repositoryName] : [], + }); + } + return [...collapsed.values()]; +} + +/** Stable key for one collapsed project-channel row. */ +export function projectRelatedChannelDisplayRowKey( + row: ProjectRelatedChannelDisplayRow, +) { + return `${row.channelId}:${row.projectId}`; +} + export type ProjectBoundChannel = { channelId: string; repositoryId: string | null; diff --git a/desktop/src/features/projects/lib/projectsActivityDigest.test.mjs b/desktop/src/features/projects/lib/projectsActivityDigest.test.mjs new file mode 100644 index 00000000000..38832d05060 --- /dev/null +++ b/desktop/src/features/projects/lib/projectsActivityDigest.test.mjs @@ -0,0 +1,57 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { buildProjectsActivityDigest } from "./projectsActivityDigest.ts"; + +const NOW = 2_000_000_000; + +test("summarizes recent activity in a short highlighted sentence", () => { + const project = { id: "project-a" }; + const digest = buildProjectsActivityDigest({ + issues: [ + { project, issue: { createdAt: NOW - 60 } }, + { project, issue: { createdAt: NOW - 120 } }, + ], + nowSeconds: NOW, + projects: [project], + pullRequests: [{ project, pullRequest: { createdAt: NOW - 180 } }], + snapshots: { + "project-a": { + commits: [ + { timestamp: NOW - 30 }, + { timestamp: NOW - 90 }, + { timestamp: NOW - 8 * 24 * 60 * 60 }, + ], + }, + }, + }); + + assert.equal(digest.prefix, "This week:"); + assert.deepEqual(digest.highlights, [ + "2 new commits", + "2 tasks opened", + "1 review opened", + "1 active project", + ]); + assert.ok( + `${digest.prefix} ${digest.highlights.join(", ")}${digest.suffix}`.split( + /\s+/, + ).length <= 30, + ); +}); + +test("falls back to current totals when no recent activity is loaded", () => { + const digest = buildProjectsActivityDigest({ + issues: [], + nowSeconds: NOW, + projects: [{ id: "a" }, { id: "b" }], + pullRequests: [], + summaries: { + a: { issueCount: 4, prCount: 2 }, + b: { issueCount: 1, prCount: 3 }, + }, + }); + + assert.equal(digest.prefix, "Currently tracking"); + assert.deepEqual(digest.highlights, ["2 projects", "5 tasks", "5 reviews"]); +}); diff --git a/desktop/src/features/projects/lib/projectsActivityDigest.ts b/desktop/src/features/projects/lib/projectsActivityDigest.ts new file mode 100644 index 00000000000..08edc4fdda5 --- /dev/null +++ b/desktop/src/features/projects/lib/projectsActivityDigest.ts @@ -0,0 +1,88 @@ +import type { + Project, + ProjectActivitySummary, + ProjectIssueListItem, + ProjectPullRequestListItem, + ProjectRepoSnapshot, +} from "@/features/projects/hooks"; + +const WEEK_SECONDS = 7 * 24 * 60 * 60; + +export type ProjectsActivityDigest = { + highlights: string[]; + prefix: string; + suffix: string; +}; + +function plural(count: number, singular: string, pluralForm = `${singular}s`) { + return `${count} ${count === 1 ? singular : pluralForm}`; +} + +/** Builds a short, deterministic sentence from the currently loaded activity. */ +export function buildProjectsActivityDigest({ + issues, + nowSeconds, + projects, + pullRequests, + snapshots, + summaries, +}: { + issues: ProjectIssueListItem[]; + nowSeconds: number; + projects: Project[]; + pullRequests: ProjectPullRequestListItem[]; + snapshots?: Record; + summaries?: Record; +}): ProjectsActivityDigest { + const since = nowSeconds - WEEK_SECONDS; + const activeProjectIds = new Set(); + let commitCount = 0; + for (const [projectId, snapshot] of Object.entries(snapshots ?? {})) { + const recent = snapshot.commits.filter( + (commit) => commit.timestamp >= since, + ).length; + commitCount += recent; + if (recent > 0) activeProjectIds.add(projectId); + } + const taskCount = issues.filter(({ issue, project }) => { + const recent = issue.createdAt >= since; + if (recent) activeProjectIds.add(project.id); + return recent; + }).length; + const reviewCount = pullRequests.filter(({ project, pullRequest }) => { + const recent = pullRequest.createdAt >= since; + if (recent) activeProjectIds.add(project.id); + return recent; + }).length; + const highlights = [ + commitCount > 0 ? `${plural(commitCount, "new commit")}` : null, + taskCount > 0 ? `${plural(taskCount, "task")} opened` : null, + reviewCount > 0 ? `${plural(reviewCount, "review")} opened` : null, + ].filter((value): value is string => value !== null); + + if (highlights.length > 0) { + highlights.push(`${plural(activeProjectIds.size, "active project")}`); + return { + highlights, + prefix: "This week:", + suffix: ".", + }; + } + + const totals = Object.values(summaries ?? {}).reduce( + (result, summary) => ({ + reviews: result.reviews + summary.prCount, + tasks: result.tasks + summary.issueCount, + }), + { reviews: 0, tasks: 0 }, + ); + return { + highlights: [ + plural(projects.length, "project"), + plural(totals.tasks, "task"), + plural(totals.reviews, "review"), + ], + prefix: "Currently tracking", + suffix: ".", + }; +} diff --git a/desktop/src/features/projects/lib/projectsSearch.test.mjs b/desktop/src/features/projects/lib/projectsSearch.test.mjs new file mode 100644 index 00000000000..47fa06e89dc --- /dev/null +++ b/desktop/src/features/projects/lib/projectsSearch.test.mjs @@ -0,0 +1,25 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { matchesProjectsSearch } from "./projectsSearch.ts"; + +test("matches every case-insensitive token across fields", () => { + assert.equal( + matchesProjectsSearch("buzz mobile", [ + "Buzz Platform", + "Desktop, relay, and mobile clients", + ]), + true, + ); + assert.equal( + matchesProjectsSearch("buzz missing", [ + "Buzz Platform", + "Desktop, relay, and mobile clients", + ]), + false, + ); +}); + +test("empty search matches everything", () => { + assert.equal(matchesProjectsSearch(" ", []), true); +}); diff --git a/desktop/src/features/projects/lib/projectsSearch.ts b/desktop/src/features/projects/lib/projectsSearch.ts new file mode 100644 index 00000000000..290e985e468 --- /dev/null +++ b/desktop/src/features/projects/lib/projectsSearch.ts @@ -0,0 +1,10 @@ +/** Case-insensitive token matching for Projects-local search. */ +export function matchesProjectsSearch( + query: string, + values: ReadonlyArray, +) { + const tokens = query.trim().toLocaleLowerCase().split(/\s+/).filter(Boolean); + if (tokens.length === 0) return true; + const haystack = values.filter(Boolean).join(" ").toLocaleLowerCase(); + return tokens.every((token) => haystack.includes(token)); +} diff --git a/desktop/src/features/projects/lib/useProjectSelection.tsx b/desktop/src/features/projects/lib/useProjectSelection.tsx index 551e59afd64..eec6883d4a7 100644 --- a/desktop/src/features/projects/lib/useProjectSelection.tsx +++ b/desktop/src/features/projects/lib/useProjectSelection.tsx @@ -25,10 +25,12 @@ const ProjectSelectionContext = export function ProjectSelectionProvider({ children, + onClear, onSelect, resetKey, }: { children: React.ReactNode; + onClear?: () => void; onSelect?: () => void; resetKey: string; }) { @@ -42,6 +44,9 @@ export function ProjectSelectionProvider({ } const onSelectRef = React.useRef(onSelect); onSelectRef.current = onSelect; + const onClearRef = React.useRef(onClear); + onClearRef.current = onClear; + const wasActiveRef = React.useRef(false); const clear = React.useCallback(() => { setState(EMPTY_PROJECT_SELECTION); @@ -61,7 +66,10 @@ export function ProjectSelectionProvider({ }, []); React.useEffect(() => { - if (state.items.length > 0) onSelectRef.current?.(); + const active = state.items.length > 0; + if (active && !wasActiveRef.current) onSelectRef.current?.(); + if (!active && wasActiveRef.current) onClearRef.current?.(); + wasActiveRef.current = active; }, [state.items.length]); React.useEffect(() => { diff --git a/desktop/src/features/projects/ui/DiscussionChannels.tsx b/desktop/src/features/projects/ui/DiscussionChannels.tsx index 665f77b6f2a..69a9e3e69bd 100644 --- a/desktop/src/features/projects/ui/DiscussionChannels.tsx +++ b/desktop/src/features/projects/ui/DiscussionChannels.tsx @@ -27,6 +27,7 @@ import { ProjectEntityFacepile, ProjectEntityListRow, } from "./ProjectEntityListRow"; +import { ProjectPanelState } from "./ProjectPanelState"; import { useProjectConversationPanel } from "./ProjectConversationPanelContext"; // Relay search caps a page at 500. Use the full page and surface a lower-bound @@ -400,13 +401,11 @@ export function DiscussionChannelsPanel({ } if (channels.length === 0) { return ( -

- No channels reference this repository yet. Paste its link (or a review - or task link) in a channel and it will show up here. -

+ ); } diff --git a/desktop/src/features/projects/ui/ProjectAgentChatPanel.tsx b/desktop/src/features/projects/ui/ProjectAgentChatPanel.tsx index 1820dc17408..50449df4f9f 100644 --- a/desktop/src/features/projects/ui/ProjectAgentChatPanel.tsx +++ b/desktop/src/features/projects/ui/ProjectAgentChatPanel.tsx @@ -9,6 +9,7 @@ import { normalizeRelayUrl } from "@/features/communities/communityStorage"; import { useCommunities } from "@/features/communities/useCommunities"; import type { ProjectDetailAgentContext } from "@/features/projects/lib/projectDetailAgentContext"; import { projectDetailAgentContextBlock } from "@/features/projects/lib/projectDetailAgentContext"; +import { pickDefaultProjectsAgent } from "@/features/projects/lib/projectAgentSelection"; import { restoreProjectsAgentConversation, submitProjectAgentMessage, @@ -102,7 +103,8 @@ export function ProjectAgentChatPanel({ const profileQuery = useProfileQuery(); const openDmMutation = useOpenDmMutation(); const startAgentMutation = useStartManagedAgentMutation(); - const selectedAgent = conversation?.agent ?? candidates[0] ?? null; + const selectedAgent = + conversation?.agent ?? pickDefaultProjectsAgent(candidates); const candidateProfilesQuery = useUsersBatchQuery( selectedAgent ? [selectedAgent.pubkey] : [], ); diff --git a/desktop/src/features/projects/ui/ProjectChannelHome.tsx b/desktop/src/features/projects/ui/ProjectChannelHome.tsx index e0135fc19b3..ca7a84ce069 100644 --- a/desktop/src/features/projects/ui/ProjectChannelHome.tsx +++ b/desktop/src/features/projects/ui/ProjectChannelHome.tsx @@ -19,6 +19,7 @@ import { useIdentityQuery } from "@/shared/api/hooks"; import type { RelayEvent } from "@/shared/api/types"; import type { EntityLinkTab } from "@/shared/lib/entityLink"; import { useThreadPanelWidth } from "@/shared/hooks/useThreadPanelWidth"; +import { SIDEBAR_WIDTH_MIN } from "@/shared/layout/sidebarLayout"; import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; import { DrawerPanelIcon } from "@/shared/ui/DrawerPanelIcon"; @@ -117,6 +118,7 @@ export function ProjectChannelHome({ const [workspaceDetail, setWorkspaceDetail] = React.useState(null); const summaryWidth = useThreadPanelWidth(undefined, { + minWidthPx: SIDEBAR_WIDTH_MIN, sessionKey: PROJECT_HOME_SUMMARY_WIDTH_KEY, }); const homeChannel = diff --git a/desktop/src/features/projects/ui/ProjectChannelManagement.tsx b/desktop/src/features/projects/ui/ProjectChannelManagement.tsx index 71d269a84a8..9c774c9a3b2 100644 --- a/desktop/src/features/projects/ui/ProjectChannelManagement.tsx +++ b/desktop/src/features/projects/ui/ProjectChannelManagement.tsx @@ -41,33 +41,37 @@ export function ProjectChannelManagement({ ? project.owner : undefined; - if (!canEdit) return null; - return ( <> - { - const result = await createMutation.mutateAsync({ - ...input, - ownerControlAgentPubkey, - project, - }); - toast.success(`Channel "#${result.channel.name}" created.`); - await goChannel(result.channel.id); - }} - onOpenChange={setCreateOpen} - testId="create-project-channel-dialog" - title="Create a project channel" - /> + {canEdit ? ( + { + const result = await createMutation.mutateAsync({ + ...input, + ownerControlAgentPubkey, + project, + }); + toast.success(`Channel "#${result.channel.name}" created.`); + await goChannel(result.channel.id); + }} + onOpenChange={setCreateOpen} + testId="create-project-channel-dialog" + title="Create a project channel" + /> + ) : null} + ) : undefined + } + description={ + loadError + ? "Refresh the repository or ask an agent to investigate." + : emptyRepository + ? "Ask an agent to create the initial codebase or connect an existing repository." + : "Add a README to describe setup, usage, and project context." + } + error={loadError} + panel={false} + title={ + loadError + ? "Could not load the README" + : emptyRepository + ? "No files have been pushed yet" + : "No README yet" + } + /> ); } diff --git a/desktop/src/features/projects/ui/ProjectRepositoryManagement.tsx b/desktop/src/features/projects/ui/ProjectRepositoryManagement.tsx index 84bde732974..c31db004e55 100644 --- a/desktop/src/features/projects/ui/ProjectRepositoryManagement.tsx +++ b/desktop/src/features/projects/ui/ProjectRepositoryManagement.tsx @@ -142,7 +142,7 @@ export function ProjectRepositoryManagement({ project={project} repositories={attachCandidates} /> - {canEdit && !hideTriggers ? ( + {!hideTriggers ? ( diff --git a/desktop/src/features/projects/ui/ProjectWorkspaceTabs.tsx b/desktop/src/features/projects/ui/ProjectWorkspaceTabs.tsx index 40588934b5e..cfac55fa69f 100644 --- a/desktop/src/features/projects/ui/ProjectWorkspaceTabs.tsx +++ b/desktop/src/features/projects/ui/ProjectWorkspaceTabs.tsx @@ -53,10 +53,10 @@ import { ProjectRepositoryUnavailableState } from "./ProjectRepositoryUnavailabl import { PROJECT_COLUMN_HEADER_BACKDROP_CLASS, PROJECT_DETAIL_PANEL_CLASS, - PROJECT_DETAIL_PANEL_MESSAGE_CLASS, PROJECT_SECTION_HEADER_CLASS, } from "./projectPanelStyles"; import { ProjectSectionHeader } from "./ProjectSectionHeader"; +import { ProjectPanelState } from "./ProjectPanelState"; import { CreatePullRequestDialog } from "./CreatePullRequestDialog"; import { CreateIssueDialog, @@ -444,7 +444,10 @@ export function WorkspaceTabs({ > {sectionHeader} - + -
- No local checkout found. -
- + ) : ( ; }; @@ -117,54 +104,6 @@ function contentPreview(content: string) { return markdownToPlainText(content).replace(/\s+/g, " ").trim().slice(0, 280); } -function activitySelectionItem( - item: ProjectActivityItem, -): ProjectSelectionItem | null { - const project = item.target.project; - const repository = - item.target.type === "issue" || item.target.type === "pull-request" - ? item.target.repository - : project.repositories[0]; - const channelId = repository?.channelId ?? project.projectChannelId; - if (item.target.type === "commit") { - return selectionItemFromCommit({ - author: item.actorPubkey, - channelId, - commitHash: item.target.commitHash, - projectId: project.id, - shareLink: repository - ? commitShareLink(repository, item.target.commitHash) - : null, - title: item.title, - }); - } - if (item.target.type === "issue") { - return selectionItemFromTask({ - author: item.target.issue.author, - channelId, - id: item.target.issue.id, - shareLink: issueShareLink(item.target.issue), - title: item.target.issue.title, - }); - } - if (item.target.type === "pull-request") { - return selectionItemFromReview({ - author: item.target.pullRequest.author, - channelId, - id: item.target.pullRequest.id, - shareLink: pullRequestShareLink(item.target.pullRequest), - title: item.target.pullRequest.title, - }); - } - return selectionItemFromProject({ - channelId: project.projectChannelId, - id: project.id, - owner: project.owner, - shareLink: projectShareLink(project), - title: project.name, - }); -} - function buildActivityItems({ issues, projects, @@ -402,7 +341,6 @@ function ActivityCard({ onOpen, onOpenProject, profiles, - rangeItems, }: { compact: boolean; isFirst: boolean; @@ -411,7 +349,6 @@ function ActivityCard({ onOpen: () => void; onOpenProject: () => void; profiles?: UserProfileLookup; - rangeItems: ProjectSelectionItem[]; }) { const visual = PROJECT_EVENT_VISUALS[item.kind]; const TypeIcon = visual.icon; @@ -421,21 +358,12 @@ function ActivityCard({ const actorLabel = item.actorPubkey ? resolveUserLabel({ profiles, pubkey: item.actorPubkey }) : item.actorName || "Someone"; - const selection = useProjectSelection(); - const selectionItem = activitySelectionItem(item); - const selected = Boolean( - selectionItem && selection?.isSelected(selectionItem.id), - ); - const showSelectControl = Boolean(selectionItem && selection && selected); - return (
+ } + description={ + error instanceof Error ? error.message : "The relay request failed." + } + error + panel={false} + title="Could not load tasks" + /> + ); } if (issues.length === 0) { return (
{loadNotice} -
- {emptyMessage} -
+
); } diff --git a/desktop/src/features/projects/ui/ProjectsListHeaderBar.tsx b/desktop/src/features/projects/ui/ProjectsListHeaderBar.tsx index 99587133974..4f1f1696ea0 100644 --- a/desktop/src/features/projects/ui/ProjectsListHeaderBar.tsx +++ b/desktop/src/features/projects/ui/ProjectsListHeaderBar.tsx @@ -1,128 +1,48 @@ import type { - ProjectsFilter, - ProjectsRepositoryScope, ProjectsSort, ProjectsViewMode, - ProjectsWorkItemScope, } from "@/features/projects/lib/projectsViewHelpers"; -import { ProjectsListScopeDropdown } from "@/features/projects/ui/ProjectsListScopeDropdown"; import { ProjectsViewModeToggle } from "@/features/projects/ui/ProjectsToolbar"; -const PROJECT_SCOPE_OPTIONS: Array<{ - label: string; - value: ProjectsRepositoryScope; -}> = [ - { label: "All", value: "all" }, - { label: "Accessible", value: "accessible" }, - { label: "My Projects", value: "mine" }, - { label: "Local", value: "local" }, -]; -const REPOSITORY_SCOPE_OPTIONS: Array<{ - label: string; - value: ProjectsRepositoryScope; -}> = [ - { label: "All", value: "all" }, - { label: "Accessible", value: "accessible" }, - { label: "My Repositories", value: "mine" }, - { label: "Local", value: "local" }, - { label: "Buzz-hosted", value: "buzz" }, - { label: "Linked", value: "linked" }, -]; -const PULL_REQUEST_SCOPE_OPTIONS: Array<{ - label: string; - value: ProjectsWorkItemScope; -}> = [ - { label: "All", value: "all" }, - { label: "My Reviews", value: "mine" }, -]; -const ISSUE_SCOPE_OPTIONS: Array<{ - label: string; - value: ProjectsWorkItemScope; -}> = [ - { label: "All", value: "all" }, - { label: "My Tasks", value: "mine" }, - { label: "Assigned to me", value: "assigned" }, -]; - type ProjectsListHeaderBarProps = { - filter: ProjectsFilter; - issueScope: ProjectsWorkItemScope; - onIssueScopeChange: (scope: ProjectsWorkItemScope) => void; - onPullRequestScopeChange: (scope: ProjectsWorkItemScope) => void; - onRepositoryScopeChange: (scope: ProjectsRepositoryScope) => void; - onSortChange: (sort: ProjectsSort) => void; onViewModeChange: (viewMode: ProjectsViewMode) => void; - pullRequestScope: ProjectsWorkItemScope; - repositoryScope: ProjectsRepositoryScope; - sort: ProjectsSort; viewMode: ProjectsViewMode; }; -/** - * Compact controls rendered in the Projects section header. - */ +/** Shared Projects sort control used by the top navigation/search row. */ +export function ProjectsSortSelect({ + onChange, + sort, +}: { + onChange: (sort: ProjectsSort) => void; + sort: ProjectsSort; +}) { + return ( + + ); +} + +/** Compact layout controls rendered in the Projects section header. */ export function ProjectsListHeaderBar({ - filter, - issueScope, - onIssueScopeChange, - onPullRequestScopeChange, - onRepositoryScopeChange, - onSortChange, onViewModeChange, - pullRequestScope, - repositoryScope, - sort, viewMode, }: ProjectsListHeaderBarProps) { - const scopeDropdown = - filter === "prs" ? ( - - ) : filter === "issues" ? ( - - ) : filter === "projects" ? ( - - ) : ( - - ); - return (
- {scopeDropdown} - void; onCreatePullRequest: () => void; onSelectSection: (section: ProjectsOverviewSection) => void; - profiles?: UserProfileLookup; projects: Project[]; pullRequests: ProjectPullRequest[]; summaries?: Record; @@ -79,7 +76,7 @@ function OverviewActionButton({ }) { return ( ); } @@ -128,43 +127,40 @@ export function ProjectsOverviewPanel({ ); } -export function ProjectsActivityIntro() { - const { activeCommunity } = useCommunities(); - const communityIconQuery = useActiveCommunityIcon(activeCommunity?.relayUrl); - const communityIcon = communityIconQuery.data ?? null; - +export function ProjectsActivityIntro({ + digest, +}: { + digest: ProjectsActivityDigest; +}) { return (
-
- {communityIcon ? ( - - ) : ( - - )} -

Projects Activity

-

- Keeping up with the community has never been easier—or mattered more. +

+ {digest.prefix}{" "} + {digest.highlights.map((highlight, index) => ( + + {index > 0 + ? index === digest.highlights.length - 1 + ? ", and " + : ", " + : null} + + {highlight} + + + ))} + {digest.suffix}

); @@ -178,7 +174,6 @@ export function ProjectsOverviewContextPanel({ onCreateProject, onCreatePullRequest, onSelectSection, - profiles, projects, pullRequests, summaries, @@ -211,7 +206,10 @@ export function ProjectsOverviewContextPanel({ return (
@@ -239,41 +237,31 @@ export function ProjectsOverviewContextPanel({
)} {selectionPresentation ? null : ( - <> -
- {context.action ? ( - - - {context.action.label} - - ) : null} -
+ {context.action ? ( + - {context.stats.map((stat) => ( - onSelectSection(stat.section)} - /> - ))} -
-
- {context.people.length > 0 ? ( -
- -
+ + {context.action.label} + ) : null} - +
+ {context.stats.map((stat) => ( + onSelectSection(stat.section)} + /> + ))} +
+
)}
diff --git a/desktop/src/features/projects/ui/ProjectsPullRequestsList.tsx b/desktop/src/features/projects/ui/ProjectsPullRequestsList.tsx index f4315576f57..1a209df6de2 100644 --- a/desktop/src/features/projects/ui/ProjectsPullRequestsList.tsx +++ b/desktop/src/features/projects/ui/ProjectsPullRequestsList.tsx @@ -21,6 +21,7 @@ import { type UserProfileLookup, } from "@/features/profile/lib/identity"; import { BuzzLoadingState } from "@/shared/ui/BuzzLoadingState"; +import { Button } from "@/shared/ui/button"; import { Card } from "@/shared/ui/card"; import { DropdownMenuItem } from "@/shared/ui/dropdown-menu"; import { CopyShareLinkMenuItem } from "./CopyShareLinkMenuItem"; @@ -30,12 +31,14 @@ import { ProjectEventTypeIcon } from "./ProjectEventTypeIcon"; import { PROJECT_GRID_CARD_BODY_CLASS } from "./projectGridCardStyles"; import { ProjectListRowMenu } from "./ProjectListRowMenu"; import { ProjectSelectableGroup } from "./ProjectSelectableGroup"; +import { ProjectPanelState } from "./ProjectPanelState"; import { ProjectsWorkItemsLoadNotice } from "./ProjectsWorkItemsLoadNotice"; import { groupProjectWorkItemsByProject } from "./projectWorkItemGroups"; type ProjectsPullRequestsListProps = { /** Render without container chrome — a parent table container provides border and rounding. */ embedded?: boolean; + emptyMessage?: string; error: unknown; failedSections: ProjectWorkItemSection[]; isLoading: boolean; @@ -199,6 +202,7 @@ const PullRequestListRow = React.memo(function PullRequestListRow({ export function ProjectsPullRequestsList({ embedded, + emptyMessage = "No reviews yet", error, failedSections, isLoading, @@ -258,21 +262,37 @@ export function ProjectsPullRequestsList({ ); if (error && pullRequests.length === 0) { - return loadNotice; + return ( + + {isRetrying ? "Retrying..." : "Retry"} + + } + description={ + error instanceof Error ? error.message : "The relay request failed." + } + error + panel={false} + title="Could not load reviews" + /> + ); } if (pullRequests.length === 0) { return (
{loadNotice} -
- No reviews yet. -
+
); } diff --git a/desktop/src/features/projects/ui/ProjectsSectionSearch.tsx b/desktop/src/features/projects/ui/ProjectsSectionSearch.tsx new file mode 100644 index 00000000000..c6b05cc9b1c --- /dev/null +++ b/desktop/src/features/projects/ui/ProjectsSectionSearch.tsx @@ -0,0 +1,157 @@ +import { Search, X } from "lucide-react"; +import { AnimatePresence, motion, useReducedMotion } from "motion/react"; +import * as React from "react"; + +import type { + ProjectsFilter, + ProjectsSort, +} from "@/features/projects/lib/projectsViewHelpers"; +import { ProjectsSortSelect } from "@/features/projects/ui/ProjectsListHeaderBar"; +import { projectsSectionTitle } from "@/features/projects/ui/projectsSectionMeta"; +import { ProjectsToolbar } from "@/features/projects/ui/ProjectsToolbar"; +import { Button } from "@/shared/ui/button"; + +export function ProjectsSectionSearch({ + filter, + onFilterChange, + onQueryChange, + onSortChange, + sort, +}: { + filter: ProjectsFilter; + onFilterChange: (filter: ProjectsFilter) => void; + onQueryChange: (query: string) => void; + onSortChange: (sort: ProjectsSort) => void; + sort: ProjectsSort; +}) { + const [open, setOpen] = React.useState(false); + const [query, setQuery] = React.useState(""); + const deferredQuery = React.useDeferredValue(query); + const focusFrameRef = React.useRef(null); + const reduceMotion = useReducedMotion(); + const transition = { + duration: reduceMotion ? 0 : 0.06, + ease: [0.2, 0.8, 0.2, 1] as const, + }; + const close = React.useCallback(() => { + setOpen(false); + setQuery(""); + onQueryChange(""); + }, [onQueryChange]); + + React.useEffect(() => { + onQueryChange(deferredQuery); + }, [deferredQuery, onQueryChange]); + const focusSearchInput = React.useCallback( + (input: HTMLInputElement | null) => { + if (!input) return; + focusFrameRef.current = window.requestAnimationFrame(() => input.focus()); + }, + [], + ); + React.useEffect( + () => () => { + if (focusFrameRef.current !== null) { + window.cancelAnimationFrame(focusFrameRef.current); + } + }, + [], + ); + + return ( +
+ +
+ + {open ? ( + + setQuery(event.target.value)} + onKeyDown={(event) => { + if (event.key !== "Escape") return; + event.preventDefault(); + close(); + }} + placeholder={`Search ${projectsSectionTitle(filter).toLocaleLowerCase()}`} + ref={focusSearchInput} + type="search" + value={query} + /> + {filter !== "all" && filter !== "channels" ? ( +
+ +
+ ) : null} +
+ ) : ( + + + + )} +
+
+
+ ); +} diff --git a/desktop/src/features/projects/ui/ProjectsSelectionCountMenu.tsx b/desktop/src/features/projects/ui/ProjectsSelectionCountMenu.tsx index b03d5e243a3..910155c4eb7 100644 --- a/desktop/src/features/projects/ui/ProjectsSelectionCountMenu.tsx +++ b/desktop/src/features/projects/ui/ProjectsSelectionCountMenu.tsx @@ -1,4 +1,4 @@ -import { Bot, GitPullRequest, Link2, X } from "lucide-react"; +import { Bot, GitPullRequest, Link2, ListChecks, X } from "lucide-react"; import * as React from "react"; import { @@ -67,13 +67,26 @@ export function ProjectsSelectionCountMenu({ return (
-

- {presentation.title} -

-
+ + + + + + Selection + +

+ {presentation.title} +

+
+
+
{presentation.actions .filter( (action) => diff --git a/desktop/src/features/projects/ui/ProjectsToolbar.tsx b/desktop/src/features/projects/ui/ProjectsToolbar.tsx index 24a933bcd66..84cf3f678e3 100644 --- a/desktop/src/features/projects/ui/ProjectsToolbar.tsx +++ b/desktop/src/features/projects/ui/ProjectsToolbar.tsx @@ -1,4 +1,5 @@ import { LayoutGrid, List } from "lucide-react"; +import { motion } from "motion/react"; import * as React from "react"; import type { @@ -26,6 +27,7 @@ const MASK_RIGHT = type ProjectsToolbarProps = { filter: ProjectsFilter; onFilterChange: (filter: ProjectsFilter) => void; + reduceMotion?: boolean; }; export function ProjectsViewModeToggle({ @@ -104,6 +106,7 @@ function useHorizontalOverflow(ref: React.RefObject) { export function ProjectsToolbar({ filter, onFilterChange, + reduceMotion = false, }: ProjectsToolbarProps) { const scrollRef = React.useRef(null); const overflow = useHorizontalOverflow(scrollRef); @@ -149,29 +152,45 @@ export function ProjectsToolbar({ > Project owner filter {filterOptions.map((option) => ( - + + ))}
diff --git a/desktop/src/features/projects/ui/ProjectsView.tsx b/desktop/src/features/projects/ui/ProjectsView.tsx index be0d3da1f0c..d8b8d86e527 100644 --- a/desktop/src/features/projects/ui/ProjectsView.tsx +++ b/desktop/src/features/projects/ui/ProjectsView.tsx @@ -1,4 +1,3 @@ -import { Search } from "lucide-react"; import * as React from "react"; import { toast } from "sonner"; @@ -17,17 +16,17 @@ import { } from "@/features/projects/hooks"; import { useRepositoryActivitySummariesQuery } from "@/features/projects/repositoryActivityHooks"; import { useCreateProjectMutation } from "@/features/projects/useCreateProject"; +import { isExplicitProject } from "@/features/projects/projectModels"; import { useProjectsRepoSnapshotsQuery } from "@/features/projects/useProjectsRepoSnapshots"; import { buildProjectSelectionAgentContext } from "@/features/projects/lib/projectDetailAgentContext"; +import { buildProjectsActivityDigest } from "@/features/projects/lib/projectsActivityDigest"; +import { matchesProjectsSearch } from "@/features/projects/lib/projectsSearch"; import type { ProjectSelectionItem } from "@/features/projects/lib/projectSelection"; import { useMemberChannelIds, useRepositoryUnavailableReasonFor, } from "@/features/projects/useRepositoryAccess"; -import { - projectRepoHostForProject, - projectRepoHostForRepository, -} from "@/features/projects/lib/projectRepoHost"; +import { projectRepoHostForProject } from "@/features/projects/lib/projectRepoHost"; import { ProjectsActivityFeed } from "@/features/projects/ui/ProjectsActivityFeed"; import { ProjectsChannelsList } from "@/features/projects/ui/ProjectsChannelsList"; import { @@ -42,7 +41,6 @@ import { import { ProjectsOverviewChromeActions } from "@/features/projects/ui/ProjectsOverviewChromeActions"; import { ProjectContextRail } from "@/features/projects/ui/ProjectContextRail"; import { - openAppSearch, projectsSectionIcon, projectsSectionTitle, } from "@/features/projects/ui/projectsSectionMeta"; @@ -60,37 +58,23 @@ import { ProjectsWorkspaceChrome } from "@/features/projects/ui/ProjectDetailChr import { ProjectsPullRequestsList } from "@/features/projects/ui/ProjectsPullRequestsList"; import { ProjectsWorkItemsLoadNotice } from "@/features/projects/ui/ProjectsWorkItemsLoadNotice"; import { ProjectsListHeaderBar } from "@/features/projects/ui/ProjectsListHeaderBar"; +import { ProjectsSectionSearch } from "@/features/projects/ui/ProjectsSectionSearch"; import { ProjectSectionHeader } from "@/features/projects/ui/ProjectSectionHeader"; import { PROJECT_COLUMN_HEADER_BACKDROP_CLASS } from "@/features/projects/ui/projectPanelStyles"; -import { ProjectsToolbar } from "@/features/projects/ui/ProjectsToolbar"; import { ProjectSelectionProvider } from "@/features/projects/lib/useProjectSelection"; -import { - hasLocalCheckout, - hasLocalRepositoryCheckout, -} from "@/features/projects/lib/projectLocalRepos"; +import { hasLocalRepositoryCheckout } from "@/features/projects/lib/projectLocalRepos"; import { getProjectUpdatedAt, - isProjectAccessibleToViewer, - isProjectMine, - isRepositoryAccessibleToViewer, projectHasAgent, projectOwnerIsUser, projectPeople, type ProjectsFilter, - type ProjectsRepositoryScope, type ProjectsSort, type ProjectsViewMode, - type ProjectsWorkItemScope, readStoredFilter, - readStoredIssueScope, - readStoredPullRequestScope, - readStoredRepositoryScope, readStoredSort, readStoredViewMode, writeStoredFilter, - writeStoredIssueScope, - writeStoredPullRequestScope, - writeStoredRepositoryScope, writeStoredSort, writeStoredViewMode, } from "@/features/projects/lib/projectsViewHelpers"; @@ -130,7 +114,11 @@ export function ProjectsView() { useProjectsScrollIndicator(); const projectsQuery = useProjectsQuery(); const identityQuery = useIdentityQuery(); - const projects = projectsQuery.data ?? []; + const projectReadModels = projectsQuery.data ?? []; + const projects = React.useMemo( + () => projectReadModels.filter(isExplicitProject), + [projectReadModels], + ); const localRepositoriesQuery = useProjectLocalRepositoriesQuery( activeCommunity?.reposDir, ); @@ -140,9 +128,14 @@ export function ProjectsView() { ? "repositories" : storedFilter; }); + const [searchQuery, setSearchQuery] = React.useState(""); const [overviewPanelOpen, setOverviewPanelOpen] = React.useState(true); const [narrowContextOpen, setNarrowContextOpen] = React.useState(false); const contextToggleRef = React.useRef(null); + const selectionDrawerStateRef = React.useRef<{ + narrow: boolean; + open: boolean; + } | null>(null); const isNarrowProjectsLayout = useMediaBreakpoint( PROJECTS_CONTEXT_POD_MIN_VIEWPORT_PX, ); @@ -150,20 +143,7 @@ export function ProjectsView() { useProjectPanelWidths("chat"); const activitySummariesQuery = useProjectActivitySummariesQuery(projects); const repositoryActivitySummariesQuery = useRepositoryActivitySummariesQuery( - filter === "repositories" ? projects : [], - ); - const [repositoryScope, setRepositoryScope] = - React.useState(() => { - const storedScope = readStoredRepositoryScope(); - return filter === "projects" && - (storedScope === "buzz" || storedScope === "linked") - ? "all" - : storedScope; - }); - const [pullRequestScope, setPullRequestScope] = - React.useState(() => readStoredPullRequestScope()); - const [issueScope, setIssueScope] = React.useState( - () => readStoredIssueScope(), + filter === "repositories" ? projectReadModels : [], ); const projectsWorkItemsQuery = useProjectsWorkItemsQuery(projects); // One blobless clone per primary Buzz repository, only while the overview @@ -231,6 +211,23 @@ export function ProjectsView() { enabled: projectPubkeys.length > 0, }); const profiles = profilesQuery.data?.profiles; + const activityDigest = React.useMemo( + () => + buildProjectsActivityDigest({ + issues: projectsWorkItemsQuery.data?.issues.items ?? [], + nowSeconds: Math.floor(Date.now() / 1_000), + projects, + pullRequests: projectsWorkItemsQuery.data?.pullRequests.items ?? [], + snapshots: repoSnapshotsQuery.data?.snapshots, + summaries: activitySummariesQuery.data, + }), + [ + activitySummariesQuery.data, + projects, + projectsWorkItemsQuery.data, + repoSnapshotsQuery.data?.snapshots, + ], + ); const deleteProjectMutation = useDeleteProjectMutation(); const currentPubkey = identityQuery.data?.pubkey; @@ -242,30 +239,6 @@ export function ProjectsView() { [], ); - const handleRepositoryScopeChange = React.useCallback( - (scope: ProjectsRepositoryScope) => { - setRepositoryScope(scope); - writeStoredRepositoryScope(scope); - }, - [], - ); - - const handlePullRequestScopeChange = React.useCallback( - (scope: ProjectsWorkItemScope) => { - setPullRequestScope(scope); - writeStoredPullRequestScope(scope); - }, - [], - ); - - const handleIssueScopeChange = React.useCallback( - (scope: ProjectsWorkItemScope) => { - setIssueScope(scope); - writeStoredIssueScope(scope); - }, - [], - ); - const handleSortChange = React.useCallback((nextSort: ProjectsSort) => { setSort(nextSort); writeStoredSort(nextSort); @@ -281,16 +254,6 @@ export function ProjectsView() { [localRepositoriesQuery.data], ); - const repositoryAccessInput = React.useMemo( - () => ({ - currentPubkey, - localRepoNames, - memberChannelIds, - relayOrigin, - }), - [currentPubkey, localRepoNames, memberChannelIds, relayOrigin], - ); - const visibleProjects = React.useMemo(() => { if (filter !== "projects" && filter !== "agents" && filter !== "users") { return []; @@ -298,22 +261,20 @@ export function ProjectsView() { const sortedProjects = projects .filter((project) => { + if ( + !matchesProjectsSearch(searchQuery, [ + project.name, + project.description, + ...project.repositories.flatMap((repository) => [ + repository.name, + repository.description, + ]), + ]) + ) { + return false; + } const summary = activitySummariesQuery.data?.[project.id]; const people = projectPeople(project, summary); - if (repositoryScope === "accessible") - return isProjectAccessibleToViewer(project, repositoryAccessInput); - if (repositoryScope === "mine") - return isProjectMine(project, currentPubkey); - if (repositoryScope === "local") - return hasLocalCheckout(project, localRepoNames); - if (repositoryScope === "buzz") - return ( - projectRepoHostForProject(project, relayOrigin).kind === "buzz" - ); - if (repositoryScope === "linked") - return ( - projectRepoHostForProject(project, relayOrigin).kind === "external" - ); if (filter === "agents") { return projectHasAgent(project, people, profiles); } @@ -338,14 +299,10 @@ export function ProjectsView() { return sortedProjects; }, [ activitySummariesQuery.data, - currentPubkey, filter, - localRepoNames, profiles, projects, - relayOrigin, - repositoryAccessInput, - repositoryScope, + searchQuery, sort, ]); @@ -353,7 +310,7 @@ export function ProjectsView() { if (filter !== "repositories") return []; const repositories = [ ...new Map( - projects + projectReadModels .flatMap((project) => project.repositories.map((repository) => ({ project, @@ -364,40 +321,13 @@ export function ProjectsView() { ).values(), ]; return repositories - .filter(({ repository }) => { - if (repositoryScope === "accessible") { - return isRepositoryAccessibleToViewer( - repository, - repositoryAccessInput, - ); - } - if (repositoryScope === "mine") { - if (!currentPubkey) return false; - const normalizedCurrentPubkey = normalizePubkey(currentPubkey); - return ( - normalizePubkey(repository.owner) === normalizedCurrentPubkey || - repository.contributors.some( - (pubkey) => normalizePubkey(pubkey) === normalizedCurrentPubkey, - ) - ); - } - if (repositoryScope === "local") { - return hasLocalRepositoryCheckout(repository, localRepoNames); - } - if (repositoryScope === "buzz") { - return ( - projectRepoHostForRepository(repository, relayOrigin).kind === - "buzz" - ); - } - if (repositoryScope === "linked") { - return ( - projectRepoHostForRepository(repository, relayOrigin).kind === - "external" - ); - } - return true; - }) + .filter(({ project, repository }) => + matchesProjectsSearch(searchQuery, [ + repository.name, + repository.description, + project.name, + ]), + ) .sort((left, right) => { if (sort === "name") { return left.repository.name.localeCompare(right.repository.name); @@ -414,61 +344,58 @@ export function ProjectsView() { return rightUpdatedAt - leftUpdatedAt; }); }, [ - currentPubkey, filter, - localRepoNames, - projects, - relayOrigin, - repositoryAccessInput, + projectReadModels, repositoryActivitySummariesQuery.data, - repositoryScope, + searchQuery, sort, ]); const visiblePullRequests = React.useMemo(() => { const pullRequests = projectsWorkItemsQuery.data?.pullRequests.items ?? []; - const scopedPullRequests = - pullRequestScope === "mine" && currentPubkey - ? pullRequests.filter( - ({ pullRequest }) => - normalizePubkey(pullRequest.author) === - normalizePubkey(currentPubkey), - ) - : pullRequests; - return [...scopedPullRequests].sort((left, right) => { - if (sort === "name") { - return left.pullRequest.title.localeCompare(right.pullRequest.title); - } - if (sort === "created") { - return right.pullRequest.createdAt - left.pullRequest.createdAt; - } - return right.pullRequest.updatedAt - left.pullRequest.updatedAt; - }); - }, [currentPubkey, projectsWorkItemsQuery.data, pullRequestScope, sort]); + return pullRequests + .filter(({ project, pullRequest, repository }) => + matchesProjectsSearch(searchQuery, [ + pullRequest.title, + pullRequest.content, + pullRequest.status, + project.name, + repository.name, + ]), + ) + .sort((left, right) => { + if (sort === "name") { + return left.pullRequest.title.localeCompare(right.pullRequest.title); + } + if (sort === "created") { + return right.pullRequest.createdAt - left.pullRequest.createdAt; + } + return right.pullRequest.updatedAt - left.pullRequest.updatedAt; + }); + }, [projectsWorkItemsQuery.data, searchQuery, sort]); const visibleIssues = React.useMemo(() => { const issues = projectsWorkItemsQuery.data?.issues.items ?? []; - const viewer = currentPubkey ? normalizePubkey(currentPubkey) : null; - const scopedIssues = - issueScope === "mine" && viewer - ? issues.filter(({ issue }) => normalizePubkey(issue.author) === viewer) - : issueScope === "assigned" && viewer - ? issues.filter(({ issue }) => - issue.assignees.some( - (assignee) => normalizePubkey(assignee) === viewer, - ), - ) - : issues; - return [...scopedIssues].sort((left, right) => { - if (sort === "name") { - return left.issue.title.localeCompare(right.issue.title); - } - if (sort === "created") { - return right.issue.createdAt - left.issue.createdAt; - } - return right.issue.updatedAt - left.issue.updatedAt; - }); - }, [currentPubkey, issueScope, projectsWorkItemsQuery.data, sort]); + return issues + .filter(({ issue, project, repository }) => + matchesProjectsSearch(searchQuery, [ + issue.title, + issue.content, + issue.status, + project.name, + repository.name, + ]), + ) + .sort((left, right) => { + if (sort === "name") { + return left.issue.title.localeCompare(right.issue.title); + } + if (sort === "created") { + return right.issue.createdAt - left.issue.createdAt; + } + return right.issue.updatedAt - left.issue.updatedAt; + }); + }, [projectsWorkItemsQuery.data, searchQuery, sort]); const { agentContext: selectionAgentContext, overviewContext: overviewAgentContext, @@ -491,18 +418,11 @@ export function ProjectsView() { // lets React keep the click responsive and paint the previous tab until // the new tree is ready instead of blocking the main thread. React.startTransition(() => { - if ( - nextFilter === "projects" && - (repositoryScope === "buzz" || repositoryScope === "linked") - ) { - setRepositoryScope("all"); - writeStoredRepositoryScope("all"); - } setSelectionAgentContext(null); setFilter(nextFilter); }); }, - [repositoryScope, setSelectionAgentContext], + [setSelectionAgentContext], ); // Route by the canonical `owner:dtag` project ID — a bare dtag is @@ -630,16 +550,7 @@ export function ProjectsView() { const listHeaderBar = ( ); @@ -675,6 +586,7 @@ export function ProjectsView() { pullRequests={ projectsWorkItemsQuery.data?.pullRequests.items ?? EMPTY_ITEMS } + searchQuery={searchQuery} snapshots={repoSnapshotsQuery.data?.snapshots} /> @@ -688,7 +600,6 @@ export function ProjectsView() { onCreateIssue: () => setCreateIssueOpen(true), onCreateProject: () => setCreateProjectOpen(true), onCreatePullRequest: () => setCreatePullRequestOpen(true), - profiles, projects, pullRequests: contextPullRequests, summaries: activitySummariesQuery.data, @@ -722,8 +633,27 @@ export function ProjectsView() { return ( { + const previous = selectionDrawerStateRef.current; + selectionDrawerStateRef.current = null; + if (!previous) return; + if (previous.narrow) { + setNarrowContextOpen(previous.open); + } else { + setOverviewPanelOpen(previous.open); + } + }} onSelect={() => { - if (!isNarrowProjectsLayout) setOverviewPanelOpen(true); + if (selectionDrawerStateRef.current) return; + selectionDrawerStateRef.current = { + narrow: isNarrowProjectsLayout, + open: isNarrowProjectsLayout ? narrowContextOpen : overviewPanelOpen, + }; + if (isNarrowProjectsLayout) { + setNarrowContextOpen(true); + } else { + setOverviewPanelOpen(true); + } }} resetKey={filter} > @@ -758,15 +688,8 @@ export function ProjectsView() { isCreating={createProjectMutation.isPending} onCreate={async (input) => { const result = await createProjectMutation.mutateAsync(input); - if (result.compatibilityWarning) { - toast.warning("Created as a standalone project", { - description: result.compatibilityWarning, - }); - } else { - toast.success(`Project "${result.project.name}" created.`); - } - handleRepositoryScopeChange("all"); - handleFilterChange("projects"); + toast.success(`Project "${result.project.name}" created.`); + await goProject(result.project.id); }} onOpenChange={setCreateProjectOpen} open={createProjectOpen} @@ -821,33 +744,22 @@ export function ProjectsView() { )} data-testid="projects-page-tabs" > - -
- -
+
{filter === "all" ? ( - +
{activityFeed}
@@ -855,7 +767,7 @@ export function ProjectsView() { ) : ( <> ) : filter === "channels" ? ( - + ) : filter === "projects" ? ( projectItems ) : ( @@ -947,11 +872,12 @@ export function ProjectsView() {