From 553495efa425febb3c61aebda2a2ff551ebe0f87 Mon Sep 17 00:00:00 2001 From: Wrench <0eabe6ea5758c1e4c5b68cea4ac42b32c479072883cb28da8110b4a47c32b9a1@buzz.block.builderlab.xyz> Date: Sun, 23 Aug 2026 17:53:34 -0400 Subject: [PATCH] feat(projects): make the home channel the project surface Co-authored-by: Wrench <0eabe6ea5758c1e4c5b68cea4ac42b32c479072883cb28da8110b4a47c32b9a1@buzz.block.builderlab.xyz> Signed-off-by: Wrench <0eabe6ea5758c1e4c5b68cea4ac42b32c479072883cb28da8110b4a47c32b9a1@buzz.block.builderlab.xyz> --- .../src/app/routes/projects.$projectId.tsx | 15 +- .../channels/ui/ChannelPane.helpers.test.mjs | 31 +- .../channels/ui/ChannelPane.helpers.ts | 28 ++ .../src/features/channels/ui/ChannelPane.tsx | 38 +- .../features/channels/ui/useChannelIntro.tsx | 12 +- .../lib/projectAgentConversation.test.mjs | 70 ++++ .../projects/lib/projectAgentConversation.ts | 18 +- .../projects/lib/projectCollection.test.mjs | 33 +- .../projects/lib/projectCollection.ts | 23 +- .../projects/lib/projectDetailSearch.test.mjs | 72 ++++ .../projects/lib/projectDetailSearch.ts | 65 +++ .../projects/lib/projectHomeChannel.test.mjs | 12 +- .../projects/lib/projectHomeChannel.ts | 4 +- .../projects/lib/projectHomeSummary.test.mjs | 10 + .../projects/lib/projectHomeSummary.ts | 6 + .../lib/projectHomeWorkspaceSheet.test.mjs | 22 ++ .../projects/lib/projectHomeWorkspaceSheet.ts | 33 ++ .../lib/projectRelatedChannels.test.mjs | 134 +++++++ .../projects/lib/projectRelatedChannels.ts | 79 ++++ .../projects/ui/ProjectAgentChatPanel.tsx | 226 ++++++----- .../projects/ui/ProjectChannelHome.tsx | 336 ++++++++++++++++ .../projects/ui/ProjectChannelManagement.tsx | 78 ++++ .../projects/ui/ProjectDetailChrome.tsx | 109 ++--- .../projects/ui/ProjectDetailScreen.tsx | 39 +- .../projects/ui/ProjectHomeCodebasePanel.tsx | 126 ++++++ .../projects/ui/ProjectHomeColumn.tsx | 67 ++++ .../projects/ui/ProjectHomeContextPanel.tsx | 373 ++++++++++++++++++ .../projects/ui/ProjectHomeWorkspaceSheet.tsx | 150 +++++++ .../ui/ProjectRepositoryManagement.tsx | 27 +- .../projects/ui/ProjectWorkspaceTabList.tsx | 86 ++-- .../projects/ui/ProjectWorkspaceTabs.tsx | 4 +- .../src/features/projects/ui/ProjectsView.tsx | 1 + .../projects/useAddProjectChannel.test.mjs | 144 +++++++ .../features/projects/useAddProjectChannel.ts | 201 ++++++++++ .../useHealProjectHomeRepositories.ts | 62 +++ .../sidebar/ui/CreateChannelDialog.tsx | 15 +- .../sidebar/ui/SidebarProjectsSection.tsx | 150 ++++--- .../sidebar/ui/listSidebarProjects.ts | 11 + desktop/src/testing/e2eBridge.ts | 31 +- desktop/tests/e2e/channel-browser.spec.ts | 3 + .../e2e/entity-link-recipient-cards.spec.ts | 5 +- .../tests/e2e/project-commit-detail.spec.ts | 299 ++++++++++---- .../tests/e2e/project-issue-comments.spec.ts | 5 +- desktop/tests/e2e/project-pr-review.spec.ts | 54 ++- .../tests/e2e/projects-v3-screenshots.spec.ts | 25 +- desktop/tests/e2e/terminal-wheel.spec.ts | 1 + 46 files changed, 2915 insertions(+), 418 deletions(-) create mode 100644 desktop/src/features/projects/lib/projectDetailSearch.test.mjs create mode 100644 desktop/src/features/projects/lib/projectDetailSearch.ts create mode 100644 desktop/src/features/projects/lib/projectHomeSummary.test.mjs create mode 100644 desktop/src/features/projects/lib/projectHomeSummary.ts create mode 100644 desktop/src/features/projects/lib/projectHomeWorkspaceSheet.test.mjs create mode 100644 desktop/src/features/projects/lib/projectHomeWorkspaceSheet.ts create mode 100644 desktop/src/features/projects/ui/ProjectChannelHome.tsx create mode 100644 desktop/src/features/projects/ui/ProjectChannelManagement.tsx create mode 100644 desktop/src/features/projects/ui/ProjectHomeCodebasePanel.tsx create mode 100644 desktop/src/features/projects/ui/ProjectHomeColumn.tsx create mode 100644 desktop/src/features/projects/ui/ProjectHomeContextPanel.tsx create mode 100644 desktop/src/features/projects/ui/ProjectHomeWorkspaceSheet.tsx create mode 100644 desktop/src/features/projects/useAddProjectChannel.test.mjs create mode 100644 desktop/src/features/projects/useAddProjectChannel.ts create mode 100644 desktop/src/features/projects/useHealProjectHomeRepositories.ts diff --git a/desktop/src/app/routes/projects.$projectId.tsx b/desktop/src/app/routes/projects.$projectId.tsx index 34af176adba..c13f21ec721 100644 --- a/desktop/src/app/routes/projects.$projectId.tsx +++ b/desktop/src/app/routes/projects.$projectId.tsx @@ -1,8 +1,8 @@ import * as React from "react"; import { createFileRoute, useLocation } from "@tanstack/react-router"; +import { parseProjectDetailSearch } from "@/features/projects/lib/projectDetailSearch"; import { usePreviewFeatureWarning } from "@/shared/features"; -import { isEntityLinkTab } from "@/shared/lib/entityLink"; import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback"; const ProjectDetailScreen = React.lazy(async () => { @@ -12,18 +12,7 @@ const ProjectDetailScreen = React.lazy(async () => { export const Route = createFileRoute("/projects/$projectId")({ component: ProjectDetailRouteComponent, - validateSearch: (search: Record) => ({ - commitHash: - typeof search.commitHash === "string" ? search.commitHash : undefined, - pullRequestId: - typeof search.pullRequestId === "string" - ? search.pullRequestId - : undefined, - issueId: typeof search.issueId === "string" ? search.issueId : undefined, - repositoryId: - typeof search.repositoryId === "string" ? search.repositoryId : undefined, - tab: isEntityLinkTab(search.tab) ? search.tab : undefined, - }), + validateSearch: parseProjectDetailSearch, }); function ProjectDetailRouteComponent() { diff --git a/desktop/src/features/channels/ui/ChannelPane.helpers.test.mjs b/desktop/src/features/channels/ui/ChannelPane.helpers.test.mjs index e4f8aa65e18..714b21c5b01 100644 --- a/desktop/src/features/channels/ui/ChannelPane.helpers.test.mjs +++ b/desktop/src/features/channels/ui/ChannelPane.helpers.test.mjs @@ -1,7 +1,10 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { getChannelIntroKind } from "./ChannelPane.helpers.ts"; +import { + getChannelIntroKind, + shouldUseFocusIdleDrawer, +} from "./ChannelPane.helpers.ts"; function channel(overrides = {}) { return { @@ -12,6 +15,32 @@ function channel(overrides = {}) { }; } +test("focus idle drawers yield to every higher-priority auxiliary surface", () => { + const idleDrawer = { + channelManagementOpen: false, + hasAgentSession: false, + hasIdleAuxiliaryPanel: true, + hasIdlePanelCloseHandler: true, + hasProfilePanel: false, + hasThreadSurface: false, + useSplitAuxiliaryPane: true, + }; + + assert.equal(shouldUseFocusIdleDrawer(idleDrawer), true); + for (const surface of [ + "channelManagementOpen", + "hasAgentSession", + "hasProfilePanel", + "hasThreadSurface", + ]) { + assert.equal( + shouldUseFocusIdleDrawer({ ...idleDrawer, [surface]: true }), + false, + `idle drawer must yield when ${surface} is open`, + ); + } +}); + test("getChannelIntroKind names project homes ahead of regular streams", () => { assert.equal(getChannelIntroKind(channel(), true), "project channel"); assert.equal(getChannelIntroKind(channel(), false), "regular channel"); diff --git a/desktop/src/features/channels/ui/ChannelPane.helpers.ts b/desktop/src/features/channels/ui/ChannelPane.helpers.ts index 2a645ba3876..8ef2ca91cb5 100644 --- a/desktop/src/features/channels/ui/ChannelPane.helpers.ts +++ b/desktop/src/features/channels/ui/ChannelPane.helpers.ts @@ -3,6 +3,34 @@ import type { TimelineMessage } from "@/features/messages/types"; import type { Channel } from "@/shared/api/types"; import { KIND_SYSTEM_MESSAGE } from "@/shared/constants/kinds"; +export function shouldUseFocusIdleDrawer({ + channelManagementOpen, + hasAgentSession, + hasIdleAuxiliaryPanel, + hasIdlePanelCloseHandler, + hasProfilePanel, + hasThreadSurface, + useSplitAuxiliaryPane, +}: { + channelManagementOpen: boolean; + hasAgentSession: boolean; + hasIdleAuxiliaryPanel: boolean; + hasIdlePanelCloseHandler: boolean; + hasProfilePanel: boolean; + hasThreadSurface: boolean; + useSplitAuxiliaryPane: boolean; +}): boolean { + return ( + useSplitAuxiliaryPane && + !channelManagementOpen && + !hasAgentSession && + !hasProfilePanel && + !hasThreadSurface && + hasIdleAuxiliaryPanel && + hasIdlePanelCloseHandler + ); +} + export function getChannelIntroKind( channel: Channel, projectHome = false, diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index beb9a992bc1..1fa03c1e2d2 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -45,7 +45,10 @@ import { WelcomeComposerGuidanceLayer, } from "@/features/channels/ui/WelcomeComposerBanner"; import { useWelcomeComposerBanner } from "@/features/channels/ui/useWelcomeComposerBanner"; -import { mentionsKnownAgent } from "@/features/channels/ui/ChannelPane.helpers"; +import { + mentionsKnownAgent, + shouldUseFocusIdleDrawer, +} from "@/features/channels/ui/ChannelPane.helpers"; import { HuddleStartingView, HuddleTranscriptIntro } from "@/features/huddle"; import { ChannelGlyph } from "@/features/channels/ui/ChannelGlyph"; import { useChannelIntro } from "@/features/channels/ui/useChannelIntro"; @@ -464,10 +467,25 @@ export const ChannelPane = React.memo(function ChannelPane({ threadViewMode === "focus" && useSplitAuxiliaryPane && (Boolean(threadHeadMessage) || shouldShowThreadSkeleton); - const useFocusIdleDrawer = - useSplitAuxiliaryPane && - Boolean(idleAuxiliaryPanel) && - Boolean(onCloseIdleAuxiliaryPanel); + const selectedAgent = React.useMemo( + () => + agentSessionSelection.resolveSelectedAgentSession({ + agentSessionAgents, + openAgentSessionPubkey, + profilePanelPubkey, + profiles, + }), + [agentSessionAgents, openAgentSessionPubkey, profilePanelPubkey, profiles], + ); + const useFocusIdleDrawer = shouldUseFocusIdleDrawer({ + channelManagementOpen, + hasAgentSession: Boolean(activeChannel && selectedAgent), + hasIdleAuxiliaryPanel: Boolean(idleAuxiliaryPanel), + hasIdlePanelCloseHandler: Boolean(onCloseIdleAuxiliaryPanel), + hasProfilePanel: Boolean(profilePanelPubkey), + hasThreadSurface: Boolean(threadHeadMessage) || shouldShowThreadSkeleton, + useSplitAuxiliaryPane, + }); const { channelIsCovered, markExitComplete } = useFocusDrawerPresence( useFocusThreadDrawer || useFocusIdleDrawer, useFocusThreadDrawer @@ -481,16 +499,6 @@ export const ChannelPane = React.memo(function ChannelPane({ onExternalTargetResolved: onThreadScrollTargetResolved, onModeChange: markExitComplete, }); - const selectedAgent = React.useMemo( - () => - agentSessionSelection.resolveSelectedAgentSession({ - agentSessionAgents, - openAgentSessionPubkey, - profilePanelPubkey, - profiles, - }), - [agentSessionAgents, openAgentSessionPubkey, profilePanelPubkey, profiles], - ); const hasSplitAuxiliaryPane = useSplitAuxiliaryPane && (channelManagementOpen || diff --git a/desktop/src/features/channels/ui/useChannelIntro.tsx b/desktop/src/features/channels/ui/useChannelIntro.tsx index 910d836ff08..5e0617abc4a 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 @@ -124,9 +128,12 @@ export function useChannelIntro({ return { actions, - channelKindLabel: getChannelIntroKind(activeChannel), + channelKindLabel: getChannelIntroKind(activeChannel, projectHome), channelName: activeChannel.name, description: getChannelIntroDescription(activeChannel), + icon: projectHome ? ( + + ) : undefined, }; }, [ activeChannel, @@ -136,5 +143,6 @@ export function useChannelIntro({ onCreateChannel, onOpenMembers, onWelcomeAddAgent, + projectHome, ]); } diff --git a/desktop/src/features/projects/lib/projectAgentConversation.test.mjs b/desktop/src/features/projects/lib/projectAgentConversation.test.mjs index 5c620c73947..a24cf3f8152 100644 --- a/desktop/src/features/projects/lib/projectAgentConversation.test.mjs +++ b/desktop/src/features/projects/lib/projectAgentConversation.test.mjs @@ -148,6 +148,53 @@ test("pointers to unknown channels or agents are not restorable", () => { ); }); +test("a stored project-channel pointer restores when it matches the home channel", () => { + const home = { + id: "project-channel-1", + channelType: "stream", + isMember: true, + memberPubkeys: [SELF_PUBKEY, AGENT_PUBKEY], + participantPubkeys: [], + }; + const restored = restoreProjectsAgentConversation({ + stored: { + agentPubkey: AGENT_PUBKEY, + channelId: home.id, + opener: OPENER, + }, + channels: [home], + candidates: [AGENT], + currentPubkey: SELF_PUBKEY, + homeChannelId: home.id, + }); + assert.equal(restored?.channel, home); + assert.equal(restored?.agent, AGENT); +}); + +test("a stored project-channel pointer does not restore a different home", () => { + const home = { + id: "project-channel-1", + channelType: "stream", + isMember: true, + memberPubkeys: [SELF_PUBKEY], + participantPubkeys: [], + }; + assert.equal( + restoreProjectsAgentConversation({ + stored: { + agentPubkey: AGENT_PUBKEY, + channelId: home.id, + opener: OPENER, + }, + channels: [home], + candidates: [AGENT], + currentPubkey: SELF_PUBKEY, + homeChannelId: "other-project-channel", + }), + null, + ); +}); + test("a pointer naming a non-DM or foreign-participant channel is not restorable", () => { const stored = { agentPubkey: AGENT_PUBKEY, @@ -532,6 +579,29 @@ test("the captured scope rides every relay side effect of a first send", async ( assert.equal(result.channel.id, "dm-on-wss://tenant-a.example"); }); +test("a home channel first send does not open a DM", async () => { + const backend = makeScopedBackend("wss://tenant-a.example"); + const home = { id: "project-channel-1" }; + const result = await submitProjectAgentMessage({ + agent: { pubkey: AGENT_PUBKEY, isManaged: false, isActive: true }, + conversation: null, + content: "build this project", + mentionPubkeys: [AGENT_PUBKEY], + relayScope: "wss://tenant-a.example", + signerScope: SELF_PUBKEY, + homeChannel: home, + startAgent: backend.startAgent, + openDm: () => { + throw new Error("project home chat must use the project channel"); + }, + send: backend.send, + }); + + assert.deepEqual(backend.state.dmOpens, []); + assert.equal(result.channel.id, home.id); + assert.equal(backend.state.sends[0].request.channelId, home.id); +}); + test("follow-ups reply to the opener so same-second id ordering cannot hide them", async () => { const backend = makeScopedBackend("wss://tenant-a.example"); await submitProjectAgentMessage({ diff --git a/desktop/src/features/projects/lib/projectAgentConversation.ts b/desktop/src/features/projects/lib/projectAgentConversation.ts index 823791edc6c..c9c35abce60 100644 --- a/desktop/src/features/projects/lib/projectAgentConversation.ts +++ b/desktop/src/features/projects/lib/projectAgentConversation.ts @@ -54,11 +54,14 @@ export function restoreProjectsAgentConversation< channels, candidates, currentPubkey, + homeChannelId, }: { stored: StoredProjectsAgentConversation | null; channels: readonly Channel[]; candidates: readonly Agent[]; currentPubkey: string | null; + /** When set, a stored pointer to this project channel (not a DM) can restore. */ + homeChannelId?: string | null; }): { channel: Channel; agent: Agent; @@ -74,9 +77,16 @@ export function restoreProjectsAgentConversation< const agent = candidates.find( (candidate) => candidate.pubkey === agentPubkey, ); - if (!channel || !agent || channel.channelType !== "dm") return null; - const participants = channel.participantPubkeys.map(normalizePubkey); + if (!channel || !agent) return null; const self = normalizePubkey(currentPubkey); + if (homeChannelId && channel.id === homeChannelId) { + // Project-home chat lives on the project channel. Membership is the + // restore proof — the channel is not a 1:1 DM. + if (!channel.isMember) return null; + return { agent, channel, opener: stored.opener }; + } + if (channel.channelType !== "dm") return null; + const participants = channel.participantPubkeys.map(normalizePubkey); const hasAgent = participants.includes(agentPubkey); // The contract is participants === {agent, self}: requiring the current // user's own membership matters as much as rejecting strangers — a stored @@ -159,6 +169,7 @@ export async function submitProjectAgentMessage({ mediaTags, relayScope, signerScope, + homeChannel, startAgent, openDm, send, @@ -174,6 +185,8 @@ export async function submitProjectAgentMessage({ /** Signing identity (owner pubkey, hex) captured together with * `relayScope`; null when unknown. */ signerScope: string | null; + /** When set, the first message lands here instead of opening a 1:1 DM. */ + homeChannel?: Ch | null; startAgent: (input: { pubkey: string; expectedRelayUrl?: string; @@ -205,6 +218,7 @@ export async function submitProjectAgentMessage({ } const channel = conversation?.channel ?? + homeChannel ?? (await openDm({ pubkeys: [agent.pubkey], expectedRelayUrl, diff --git a/desktop/src/features/projects/lib/projectCollection.test.mjs b/desktop/src/features/projects/lib/projectCollection.test.mjs index d504234e131..801f249befa 100644 --- a/desktop/src/features/projects/lib/projectCollection.test.mjs +++ b/desktop/src/features/projects/lib/projectCollection.test.mjs @@ -75,9 +75,9 @@ function standaloneRepo(overrides = {}) { }; } -test("absorbStandaloneProjectRepositories folds a home-channel repo into the project", () => { +test("absorbStandaloneProjectRepositories folds an authorized home-channel repo into the project", () => { const project = explicitProject(); - const repoCard = standaloneRepo(); + const repoCard = standaloneRepo({ repository: { maintainers: [OWNER] } }); const folded = absorbStandaloneProjectRepositories([project, repoCard]); assert.equal(folded.length, 1); @@ -87,6 +87,16 @@ test("absorbStandaloneProjectRepositories folds a home-channel repo into the pro assert.equal(folded[0].repositoryAddresses[0], repoCard.projectAddress); }); +test("absorbStandaloneProjectRepositories rejects a hostile home-channel claim", () => { + const project = explicitProject(); + const repoCard = standaloneRepo(); + const folded = absorbStandaloneProjectRepositories([project, repoCard]); + + assert.equal(folded.length, 2); + assert.deepEqual(folded[0].repositories, []); + assert.equal(folded[1].projectAddress, repoCard.projectAddress); +}); + test("absorbStandaloneProjectRepositories folds the owner's same-slug repo", () => { const project = explicitProject(); const repoCard = standaloneRepo({ @@ -115,14 +125,25 @@ test("absorbStandaloneProjectRepositories keeps an unrelated standalone repo", ( ); }); -test("homeRepositoriesToBind lists absorbed channel repos missing from the signed project", () => { +test("homeRepositoriesToBind lists authorized absorbed channel repos missing from the signed project", () => { + const repo = standaloneRepo({ repository: { maintainers: [OWNER] } }); const project = explicitProject({ - repositories: [standaloneRepo().repositories[0]], - repositoryAddresses: [standaloneRepo().projectAddress], + repositories: [repo.repositories[0]], + repositoryAddresses: [repo.projectAddress], }); const pending = homeRepositoriesToBind(project, []); assert.equal(pending.length, 1); - assert.equal(pending[0].repoAddress, standaloneRepo().projectAddress); + assert.equal(pending[0].repoAddress, repo.projectAddress); +}); + +test("homeRepositoriesToBind rejects a hostile absorbed channel repo", () => { + const repo = standaloneRepo(); + const project = explicitProject({ + repositories: [repo.repositories[0]], + repositoryAddresses: [repo.projectAddress], + }); + + assert.deepEqual(homeRepositoriesToBind(project, []), []); }); test("homeRepositoriesToBind ignores repos already on the signed project", () => { diff --git a/desktop/src/features/projects/lib/projectCollection.ts b/desktop/src/features/projects/lib/projectCollection.ts index 73924c06f4a..ea329855f09 100644 --- a/desktop/src/features/projects/lib/projectCollection.ts +++ b/desktop/src/features/projects/lib/projectCollection.ts @@ -19,13 +19,28 @@ function withAbsorbedRepository( }; } +function repositoryAuthorizesProjectOwner( + project: Project, + repository: Repository, +): boolean { + const projectOwner = project.owner.toLowerCase(); + if (repository.owner.toLowerCase() === projectOwner) return true; + return Boolean( + repository.maintainers?.some( + (maintainer) => maintainer.toLowerCase() === projectOwner, + ), + ); +} + function hostForStandaloneRepository( explicitProjects: Project[], repository: Repository, ): Project | undefined { const channelHost = repository.channelId ? explicitProjects.find( - (project) => project.projectChannelId === repository.channelId, + (project) => + project.projectChannelId === repository.channelId && + repositoryAuthorizesProjectOwner(project, repository), ) : undefined; if (channelHost) return channelHost; @@ -41,8 +56,10 @@ function repositoryBelongsOnProjectHome( ): boolean { return Boolean( (repository.channelId && - repository.channelId === project.projectChannelId) || - (repository.owner === project.owner && repository.dtag === project.dtag), + repository.channelId === project.projectChannelId && + repositoryAuthorizesProjectOwner(project, repository)) || + (repository.owner.toLowerCase() === project.owner.toLowerCase() && + repository.dtag === project.dtag), ); } diff --git a/desktop/src/features/projects/lib/projectDetailSearch.test.mjs b/desktop/src/features/projects/lib/projectDetailSearch.test.mjs new file mode 100644 index 00000000000..d9d667d73d0 --- /dev/null +++ b/desktop/src/features/projects/lib/projectDetailSearch.test.mjs @@ -0,0 +1,72 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + parseProjectDetailSearch, + wantsProjectRepositorySurface, +} from "./projectDetailSearch.ts"; + +test("parseProjectDetailSearch keeps forge params and channel panel params", () => { + const search = parseProjectDetailSearch({ + repositoryId: "30617:owner:buzz", + tab: "files", + thread: "abc123", + agentSession: "def456", + channelManagement: "1", + extra: "dropped", + }); + + assert.equal(search.repositoryId, "30617:owner:buzz"); + assert.equal(search.tab, "files"); + assert.equal(search.thread, "abc123"); + assert.equal(search.agentSession, "def456"); + assert.equal(search.channelManagement, "1"); + assert.equal("extra" in search, false); +}); + +test("parseProjectDetailSearch drops empty channel panel params", () => { + const search = parseProjectDetailSearch({ + thread: "", + messageId: "", + tab: "not-a-tab", + }); + + assert.equal(search.thread, undefined); + assert.equal(search.messageId, undefined); + assert.equal(search.tab, undefined); +}); + +test("wantsProjectRepositorySurface is false for channel-first project home", () => { + assert.equal( + wantsProjectRepositorySurface({ + projectId: "30621:owner:platform", + }), + false, + ); +}); + +test("wantsProjectRepositorySurface is true for repo, tab, or work-item params", () => { + assert.equal( + wantsProjectRepositorySurface({ + projectId: "30621:owner:platform", + repositoryId: "30617:owner:buzz", + }), + true, + ); + assert.equal( + wantsProjectRepositorySurface({ + projectId: "30621:owner:platform", + tab: "files", + }), + true, + ); +}); + +test("wantsProjectRepositorySurface is true for a legacy kind:30617 project id", () => { + assert.equal( + wantsProjectRepositorySurface({ + projectId: "30617:owner:buzz", + }), + true, + ); +}); diff --git a/desktop/src/features/projects/lib/projectDetailSearch.ts b/desktop/src/features/projects/lib/projectDetailSearch.ts new file mode 100644 index 00000000000..cfc7ece0990 --- /dev/null +++ b/desktop/src/features/projects/lib/projectDetailSearch.ts @@ -0,0 +1,65 @@ +import { + parseProfilePanelTab, + parseProfilePanelView, +} from "@/features/profile/ui/UserProfilePanelUtils"; +import { KIND_REPO_ANNOUNCEMENT } from "@/shared/constants/kinds"; +import { isEntityLinkTab } from "@/shared/lib/entityLink"; + +function optionalSearchString(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +function nonEmptyString(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +/** + * Project detail URLs carry forge params (repository, tab, issue) and, on + * channel-first home, the same auxiliary-panel params a stream channel uses + * (thread, agent session, profile). Both must survive `validateSearch` or + * ChannelScreen cannot keep threads and agent activity on this route. + */ +export function parseProjectDetailSearch(search: Record) { + return { + commitHash: optionalSearchString(search.commitHash), + pullRequestId: optionalSearchString(search.pullRequestId), + issueId: optionalSearchString(search.issueId), + repositoryId: optionalSearchString(search.repositoryId), + tab: isEntityLinkTab(search.tab) ? search.tab : undefined, + agentSession: nonEmptyString(search.agentSession), + agentSessionChannel: nonEmptyString(search.agentSessionChannel), + autoSend: nonEmptyString(search.autoSend), + channelManagement: nonEmptyString(search.channelManagement), + messageId: nonEmptyString(search.messageId), + profile: nonEmptyString(search.profile), + profileTab: parseProfilePanelTab(search.profileTab) ?? undefined, + profileView: parseProfilePanelView(search.profileView) ?? undefined, + thread: nonEmptyString(search.thread), + threadRootId: nonEmptyString(search.threadRootId), + }; +} + +/** + * Channel-first project home is the default. A repository forge surface is + * requested by an explicit repo/work-item search param, or by a legacy + * kind:30617 project id (the project *is* that repository). + */ +export function wantsProjectRepositorySurface(input: { + commitHash?: string; + issueId?: string; + projectId: string; + pullRequestId?: string; + repositoryId?: string; + tab?: string; +}): boolean { + if ( + input.repositoryId || + input.tab || + input.issueId || + input.pullRequestId || + input.commitHash + ) { + return true; + } + return input.projectId.startsWith(`${KIND_REPO_ANNOUNCEMENT}:`); +} diff --git a/desktop/src/features/projects/lib/projectHomeChannel.test.mjs b/desktop/src/features/projects/lib/projectHomeChannel.test.mjs index 5c7eeb7335c..2903873b40f 100644 --- a/desktop/src/features/projects/lib/projectHomeChannel.test.mjs +++ b/desktop/src/features/projects/lib/projectHomeChannel.test.mjs @@ -1,7 +1,10 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { isProjectHomeChannel } from "./projectHomeChannel.ts"; +import { + hasAuthoritativeHomeBinding, + isProjectHomeChannel, +} from "./projectHomeChannel.ts"; const OWNER = "a".repeat(64); const MAINTAINER = "b".repeat(64); @@ -42,6 +45,13 @@ test("isProjectHomeChannel accepts a repository that authorizes the project owne ); }); +test("hasAuthoritativeHomeBinding rejects a bare project route assertion", () => { + assert.equal( + hasAuthoritativeHomeBinding(project({ repositories: [] })), + false, + ); +}); + test("isProjectHomeChannel rejects a bare project channel assertion", () => { assert.equal( isProjectHomeChannel("channel-a", [project({ repositories: [] })]), diff --git a/desktop/src/features/projects/lib/projectHomeChannel.ts b/desktop/src/features/projects/lib/projectHomeChannel.ts index fa408467fe4..7edc9a3b6aa 100644 --- a/desktop/src/features/projects/lib/projectHomeChannel.ts +++ b/desktop/src/features/projects/lib/projectHomeChannel.ts @@ -10,7 +10,9 @@ export type ProjectHomeCandidate = { }>; }; -function hasAuthoritativeHomeBinding(project: ProjectHomeCandidate): boolean { +export function hasAuthoritativeHomeBinding( + project: ProjectHomeCandidate, +): boolean { const channelId = project.projectChannelId; if (!channelId) return false; diff --git a/desktop/src/features/projects/lib/projectHomeSummary.test.mjs b/desktop/src/features/projects/lib/projectHomeSummary.test.mjs new file mode 100644 index 00000000000..7e24b2f6385 --- /dev/null +++ b/desktop/src/features/projects/lib/projectHomeSummary.test.mjs @@ -0,0 +1,10 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { presentContextCount } from "./projectHomeSummary.ts"; + +test("presentContextCount hides empty values", () => { + assert.equal(presentContextCount(undefined), undefined); + assert.equal(presentContextCount(0), undefined); + assert.equal(presentContextCount(3), 3); +}); diff --git a/desktop/src/features/projects/lib/projectHomeSummary.ts b/desktop/src/features/projects/lib/projectHomeSummary.ts new file mode 100644 index 00000000000..8928276002f --- /dev/null +++ b/desktop/src/features/projects/lib/projectHomeSummary.ts @@ -0,0 +1,6 @@ +/** Right-edge context counts omit empty values, matching the Projects overview. */ +export function presentContextCount( + value: number | undefined, +): number | undefined { + return value != null && value > 0 ? value : undefined; +} diff --git a/desktop/src/features/projects/lib/projectHomeWorkspaceSheet.test.mjs b/desktop/src/features/projects/lib/projectHomeWorkspaceSheet.test.mjs new file mode 100644 index 00000000000..89d23eb2217 --- /dev/null +++ b/desktop/src/features/projects/lib/projectHomeWorkspaceSheet.test.mjs @@ -0,0 +1,22 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + isProjectHomeWorkspaceSheetTab, + projectHomeWorkspaceSheetTitle, +} from "./projectHomeWorkspaceSheet.ts"; + +test("isProjectHomeWorkspaceSheetTab accepts overview workspace rows", () => { + assert.equal(isProjectHomeWorkspaceSheetTab("issues"), true); + assert.equal(isProjectHomeWorkspaceSheetTab("files"), true); + assert.equal(isProjectHomeWorkspaceSheetTab("channels"), false); + assert.equal(isProjectHomeWorkspaceSheetTab(undefined), false); +}); + +test("projectHomeWorkspaceSheetTitle matches overview row labels", () => { + assert.equal(projectHomeWorkspaceSheetTitle("issues"), "Tasks"); + assert.equal(projectHomeWorkspaceSheetTitle("prs"), "Reviews"); + assert.equal(projectHomeWorkspaceSheetTitle("commits"), "Commits"); + assert.equal(projectHomeWorkspaceSheetTitle("files"), "Files"); + assert.equal(projectHomeWorkspaceSheetTitle("contributors"), "People"); +}); diff --git a/desktop/src/features/projects/lib/projectHomeWorkspaceSheet.ts b/desktop/src/features/projects/lib/projectHomeWorkspaceSheet.ts new file mode 100644 index 00000000000..93a6675990a --- /dev/null +++ b/desktop/src/features/projects/lib/projectHomeWorkspaceSheet.ts @@ -0,0 +1,33 @@ +export const PROJECT_HOME_WORKSPACE_SHEET_TABS = [ + "issues", + "prs", + "commits", + "files", + "contributors", +] as const; + +export type ProjectHomeWorkspaceSheetTab = + (typeof PROJECT_HOME_WORKSPACE_SHEET_TABS)[number]; + +export function isProjectHomeWorkspaceSheetTab( + value: string | undefined, +): value is ProjectHomeWorkspaceSheetTab { + return ( + value != null && + (PROJECT_HOME_WORKSPACE_SHEET_TABS as readonly string[]).includes(value) + ); +} + +const WORKSPACE_SHEET_TITLES: Record = { + commits: "Commits", + contributors: "People", + files: "Files", + issues: "Tasks", + prs: "Reviews", +}; + +export function projectHomeWorkspaceSheetTitle( + tab: ProjectHomeWorkspaceSheetTab, +): string { + return WORKSPACE_SHEET_TITLES[tab]; +} diff --git a/desktop/src/features/projects/lib/projectRelatedChannels.test.mjs b/desktop/src/features/projects/lib/projectRelatedChannels.test.mjs index ef94588ccc3..ad1795ad735 100644 --- a/desktop/src/features/projects/lib/projectRelatedChannels.test.mjs +++ b/desktop/src/features/projects/lib/projectRelatedChannels.test.mjs @@ -3,6 +3,8 @@ import { test } from "node:test"; import { collectProjectRelatedChannelRows, + listProjectBoundChannels, + listProjectChildChannels, projectRelatedChannelRowKey, uniqueProjectRelatedChannelCount, } from "./projectRelatedChannels.ts"; @@ -183,3 +185,135 @@ test("row keys distinguish project-level bindings from repository bindings", () `${CHANNEL_A}:project-buzz:repo-buzz`, ); }); + +test("listProjectBoundChannels puts the home channel first", () => { + assert.deepEqual( + listProjectBoundChannels( + makeProject({ + projectChannelId: CHANNEL_B, + repositories: [ + makeRepository({ channelId: CHANNEL_A }), + makeRepository({ + id: "repo-relay", + name: "relay-tools", + channelId: CHANNEL_A, + }), + ], + }), + ), + [ + { + channelId: CHANNEL_B, + repositoryId: null, + role: "home", + }, + { + channelId: CHANNEL_A, + repositoryId: "repo-buzz", + role: "related", + }, + ], + ); +}); + +test("listProjectBoundChannels omits a repository channel that is the home channel", () => { + assert.deepEqual( + listProjectBoundChannels( + makeProject({ + projectChannelId: CHANNEL_A, + repositories: [makeRepository({ channelId: CHANNEL_A })], + }), + ), + [ + { + channelId: CHANNEL_A, + repositoryId: null, + role: "home", + }, + ], + ); +}); + +test("listProjectBoundChannels is empty when nothing is bound", () => { + assert.deepEqual(listProjectBoundChannels(makeProject()), []); +}); + +test("listProjectBoundChannels includes extra related channels after home", () => { + const related = "33333333-3333-4333-8333-333333333333"; + assert.deepEqual( + listProjectBoundChannels( + makeProject({ + projectChannelId: CHANNEL_B, + relatedChannelIds: [related], + repositories: [makeRepository({ channelId: CHANNEL_A })], + }), + ), + [ + { + channelId: CHANNEL_B, + repositoryId: null, + role: "home", + }, + { + channelId: related, + repositoryId: null, + role: "related", + }, + { + channelId: CHANNEL_A, + repositoryId: "repo-buzz", + role: "related", + }, + ], + ); +}); + +test("listProjectChildChannels omits the home channel", () => { + const related = "33333333-3333-4333-8333-333333333333"; + assert.deepEqual( + listProjectChildChannels( + makeProject({ + projectChannelId: CHANNEL_B, + relatedChannelIds: [related], + repositories: [makeRepository({ channelId: CHANNEL_A })], + }), + ), + [ + { + channelId: related, + repositoryId: null, + role: "related", + }, + { + channelId: CHANNEL_A, + repositoryId: "repo-buzz", + role: "related", + }, + ], + ); +}); + +test("collectProjectRelatedChannelRows includes extra related channels", () => { + const related = "33333333-3333-4333-8333-333333333333"; + const rows = collectProjectRelatedChannelRows([ + makeProject({ + projectChannelId: CHANNEL_B, + relatedChannelIds: [related, CHANNEL_A], + repositories: [makeRepository()], + }), + ]); + assert.equal( + rows.some((row) => row.channelId === related && row.repositoryId == null), + true, + ); + assert.equal( + uniqueProjectRelatedChannelCount([ + makeProject({ + projectChannelId: CHANNEL_B, + relatedChannelIds: [related], + repositories: [makeRepository()], + }), + ]), + 3, + ); +}); diff --git a/desktop/src/features/projects/lib/projectRelatedChannels.ts b/desktop/src/features/projects/lib/projectRelatedChannels.ts index 102e9d9a03c..525eb700c26 100644 --- a/desktop/src/features/projects/lib/projectRelatedChannels.ts +++ b/desktop/src/features/projects/lib/projectRelatedChannels.ts @@ -4,6 +4,7 @@ export type ProjectRelatedChannelSource = { id: string; name: string; projectChannelId: string | null; + relatedChannelIds?: readonly string[]; repositories: Array<{ id: string; name: string; @@ -57,6 +58,19 @@ export function collectProjectRelatedChannelRows( repositoryId: null, repositoryName: null, }); + repositoryChannelIds.add(projectChannelId); + } + for (const relatedChannelId of project.relatedChannelIds ?? []) { + const channelId = trimmedChannelId(relatedChannelId); + if (!channelId || repositoryChannelIds.has(channelId)) continue; + repositoryChannelIds.add(channelId); + rows.push({ + channelId, + projectId: project.id, + projectName: project.name, + repositoryId: null, + repositoryName: null, + }); } } return rows; @@ -73,3 +87,68 @@ export function uniqueProjectRelatedChannelCount( export function projectRelatedChannelRowKey(row: ProjectRelatedChannelRow) { return `${row.channelId}:${row.projectId}:${row.repositoryId ?? "project"}`; } + +export type ProjectBoundChannel = { + channelId: string; + repositoryId: string | null; + role: "home" | "related"; +}; + +/** + * Unique channels bound to one project: the home stream first, then each + * repository channel that is not already the home channel. + */ +export function listProjectBoundChannels( + project: Pick< + ProjectRelatedChannelSource, + "projectChannelId" | "relatedChannelIds" | "repositories" + >, +): ProjectBoundChannel[] { + const channels: ProjectBoundChannel[] = []; + const seen = new Set(); + const homeChannelId = trimmedChannelId(project.projectChannelId); + if (homeChannelId) { + channels.push({ + channelId: homeChannelId, + repositoryId: null, + role: "home", + }); + seen.add(homeChannelId); + } + for (const relatedChannelId of project.relatedChannelIds ?? []) { + const channelId = trimmedChannelId(relatedChannelId); + if (!channelId || seen.has(channelId)) continue; + channels.push({ + channelId, + repositoryId: null, + role: "related", + }); + seen.add(channelId); + } + for (const repository of project.repositories) { + const channelId = trimmedChannelId(repository.channelId); + if (!channelId || seen.has(channelId)) continue; + channels.push({ + channelId, + repositoryId: repository.id, + role: "related", + }); + seen.add(channelId); + } + return channels; +} + +/** + * Nested sidebar rows under a project: bound streams except the home + * channel, which is the project row itself. + */ +export function listProjectChildChannels( + project: Pick< + ProjectRelatedChannelSource, + "projectChannelId" | "relatedChannelIds" | "repositories" + >, +): ProjectBoundChannel[] { + return listProjectBoundChannels(project).filter( + (channel) => channel.role !== "home", + ); +} diff --git a/desktop/src/features/projects/ui/ProjectAgentChatPanel.tsx b/desktop/src/features/projects/ui/ProjectAgentChatPanel.tsx index 14013a6157a..1820dc17408 100644 --- a/desktop/src/features/projects/ui/ProjectAgentChatPanel.tsx +++ b/desktop/src/features/projects/ui/ProjectAgentChatPanel.tsx @@ -24,6 +24,7 @@ import { import { MessageComposer } from "@/features/messages/ui/MessageComposer"; import { useProfileQuery, useUsersBatchQuery } from "@/features/profile/hooks"; import { useIdentityQuery } from "@/shared/api/hooks"; +import { addChannelMembers } from "@/shared/api/tauri"; import { sendChannelMessage } from "@/shared/api/tauriMessages"; import type { Channel } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; @@ -45,25 +46,29 @@ type ProjectAgentConversation = { }; export function ProjectAgentChatPanel({ - canResetWidth, + canResetWidth = false, constrainToAvailableSpace = true, context, detached = false, + homeChannel = null, + layout = "pane", onClose, onResetWidth, onResizeStart, sharedHeaderBackdrop, - widthPx, + widthPx = 0, }: { - canResetWidth: boolean; + canResetWidth?: boolean; constrainToAvailableSpace?: boolean; context: ProjectDetailAgentContext; detached?: boolean; + homeChannel?: Channel | null; + layout?: "pane" | "canvas"; onClose?: () => void; - onResetWidth: () => void; - onResizeStart: (event: React.PointerEvent) => void; + onResetWidth?: () => void; + onResizeStart?: (event: React.PointerEvent) => void; sharedHeaderBackdrop?: boolean; - widthPx: number; + widthPx?: number; }) { const { activeCommunity } = useCommunities(); const identityQuery = useIdentityQuery(); @@ -119,11 +124,13 @@ export function ProjectAgentChatPanel({ candidates, channels: channelsQuery.data ?? [], currentPubkey: identityQuery.data?.pubkey ?? null, + homeChannelId: homeChannel?.id ?? null, stored: storedConversation, }), [ candidates, channelsQuery.data, + homeChannel?.id, identityQuery.data?.pubkey, storedConversation, ], @@ -148,10 +155,24 @@ export function ProjectAgentChatPanel({ // `submitProjectAgentMessage` binds every relay side effect to the // scope captured here (fail closed), and threads follow-ups onto the // opener so a same-second follow-up cannot be hidden by id ordering. + if (homeChannel) { + const alreadyMember = homeChannel.memberPubkeys.some( + (pubkey) => + normalizePubkey(pubkey) === normalizePubkey(selectedAgent.pubkey), + ); + if (!alreadyMember) { + await addChannelMembers({ + channelId: homeChannel.id, + pubkeys: [selectedAgent.pubkey], + role: "bot", + }); + } + } const { channel, sent } = await submitProjectAgentMessage({ agent: selectedAgent, conversation, content: `${trimmed}${contextPayload}`, + homeChannel, mentionPubkeys: [ ...new Set([...mentionPubkeys, selectedAgent.pubkey]), ], @@ -209,6 +230,7 @@ export function ProjectAgentChatPanel({ [ contextPayload, conversation, + homeChannel, identityQuery.data?.pubkey, isSending, openDmMutation, @@ -225,98 +247,122 @@ export function ProjectAgentChatPanel({ setConversation(null); }, [storageScope]); - return ( - -
+ {layout === "pane" ? ( -
-
- {conversation ? ( - - ) : ( -
-

- Ask about this page -

-

- Start a conversation with the project agent. -

-
- )} -
- {context.selection?.length ? ( - - ) : null} - - - {conversation ? ( - - ) : null} - - } - /> + ) : null} +
+
+ {conversation ? ( + + ) : ( +
+

+ {homeChannel + ? "Explain what this project should be" + : "Ask about this page"} +

+

+ {homeChannel + ? "The project agent will build it out from this channel." + : "Start a conversation with the project agent."} +

+
+ )}
+ {context.selection?.length ? ( + + ) : null} + + + {conversation ? ( + + ) : null} + + } + /> +
+
+ ); + + if (layout === "canvas") { + return ( +
+ {conversationBody}
+ ); + } + + return ( + {})} + onResizeStart={onResizeStart ?? (() => {})} + testId="project-agent-chat-panel" + widthPx={widthPx} + > + {conversationBody} ); } diff --git a/desktop/src/features/projects/ui/ProjectChannelHome.tsx b/desktop/src/features/projects/ui/ProjectChannelHome.tsx new file mode 100644 index 00000000000..a0b931d9e4b --- /dev/null +++ b/desktop/src/features/projects/ui/ProjectChannelHome.tsx @@ -0,0 +1,336 @@ +import { useSearch } from "@tanstack/react-router"; +import { Info } from "lucide-react"; +import * as React from "react"; + +import { useAppNavigation } from "@/app/navigation/useAppNavigation"; +import { useChannelsQuery } from "@/features/channels/hooks"; +import { ChannelScreenLoadingFallback } from "@/features/channels/ui/ChannelScreenLoadingFallback"; +import { useProfileQuery } from "@/features/profile/hooks"; +import type { Project } from "@/features/projects/hooks"; +import { + isProjectHomeWorkspaceSheetTab, + projectHomeWorkspaceSheetTitle, + type ProjectHomeWorkspaceSheetTab, +} from "@/features/projects/lib/projectHomeWorkspaceSheet"; +import { useHealProjectHomeRepositories } from "@/features/projects/useHealProjectHomeRepositories"; +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 { cn } from "@/shared/lib/cn"; +import { Button } from "@/shared/ui/button"; +import { DrawerPanelIcon } from "@/shared/ui/DrawerPanelIcon"; +import { useOptionalSidebar } from "@/shared/ui/sidebar"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; +import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback"; +import { ProjectContextRail } from "./ProjectContextRail"; +import { ProjectDetailChrome } from "./ProjectDetailChrome"; +import { ProjectHomeColumn } from "./ProjectHomeColumn"; +import { ProjectHomeContextPanel } from "./ProjectHomeContextPanel"; +import { ProjectHomeWorkspaceSheet } from "./ProjectHomeWorkspaceSheet"; +import { ProjectRepositoryManagement } from "./ProjectRepositoryManagement"; + +const EMPTY_TARGET_MESSAGE_EVENTS: RelayEvent[] = []; +const PROJECT_HOME_SUMMARY_WIDTH_KEY = + "buzz.desktop.project-home-summary-width"; + +const ChannelScreenView = React.lazy(async () => { + const module = await import("@/features/channels/ui/ChannelScreen"); + return { default: module.ChannelScreen }; +}); + +function ignoreForumPost() {} +function ignoreForumPostSelect() {} + +function ProjectHomeHeaderToggle({ + children, + label, + onClick, + open, + testId, +}: { + children: React.ReactNode; + label: string; + onClick: () => void; + open: boolean; + testId: string; +}) { + return ( + + + + + {label} + + ); +} + +export function ProjectChannelHome({ + project, + projects, +}: { + project: Project; + projects: Project[]; +}) { + const { goChannel, goProject, goProjects } = useAppNavigation(); + const sidebar = useOptionalSidebar(); + const identityQuery = useIdentityQuery(); + const profileQuery = useProfileQuery(); + const channelsQuery = useChannelsQuery(); + const search = useSearch({ strict: false }) as { + autoSend?: string; + messageId?: string; + }; + const [summaryOpen, setSummaryOpen] = React.useState(true); + const [addRepositoryOpen, setAddRepositoryOpen] = React.useState(false); + const [workspaceSheetTab, setWorkspaceSheetTab] = + React.useState(null); + const [workspaceRepositoryId, setWorkspaceRepositoryId] = React.useState< + string | null + >(null); + const summaryWidth = useThreadPanelWidth(undefined, { + sessionKey: PROJECT_HOME_SUMMARY_WIDTH_KEY, + }); + const homeChannel = + channelsQuery.data?.find( + (channel) => channel.id === project.projectChannelId, + ) ?? null; + const waitingForChannel = channelsQuery.isPending && !homeChannel; + const workspaceRepository = + project.repositories.find( + (repository) => repository.id === workspaceRepositoryId, + ) ?? + project.repositories[0] ?? + null; + const workspaceSheetOpen = + workspaceSheetTab != null && workspaceRepository != null; + const summaryVisible = summaryOpen && !workspaceSheetOpen; + + const openWorkspaceSheet = React.useCallback( + (tab: ProjectHomeWorkspaceSheetTab, repositoryId?: string) => { + if (repositoryId) { + setWorkspaceRepositoryId(repositoryId); + } + setWorkspaceSheetTab((current) => (current === tab ? null : tab)); + }, + [], + ); + const closeWorkspaceSheet = React.useCallback(() => { + setWorkspaceSheetTab(null); + }, []); + const handleOpenWorkspace = React.useCallback( + (repositoryId: string, tab?: EntityLinkTab) => { + if (!isProjectHomeWorkspaceSheetTab(tab)) { + void goProject(project.id, { repositoryId, tab }); + return; + } + openWorkspaceSheet(tab, repositoryId); + }, + [goProject, openWorkspaceSheet, project.id], + ); + const handleOpenRepository = React.useCallback( + (repositoryId: string) => { + void goProject(project.id, { repositoryId }); + }, + [goProject, project.id], + ); + const handleRepositoryChange = React.useCallback(() => { + void goProject(project.id); + }, [goProject, project.id]); + const handleAddFiles = React.useCallback(() => { + setAddRepositoryOpen(true); + }, []); + const handleFilesAdded = React.useCallback((repositoryId: string) => { + setWorkspaceRepositoryId(repositoryId); + setWorkspaceSheetTab("files"); + }, []); + const handleToggleFilesSheet = React.useCallback(() => { + if (!workspaceRepository) { + handleAddFiles(); + return; + } + openWorkspaceSheet("files", workspaceRepository.id); + }, [handleAddFiles, openWorkspaceSheet, workspaceRepository]); + useHealProjectHomeRepositories(project, identityQuery.data?.pubkey); + const handleOpenCommit = React.useCallback( + (commitHash: string) => { + if (!workspaceRepository) return; + void goProject(project.id, { + commitHash, + repositoryId: workspaceRepository.id, + tab: "commits", + }); + }, + [goProject, project.id, workspaceRepository], + ); + const workspaceSheet = + workspaceSheetOpen && workspaceSheetTab && workspaceRepository ? ( + + ) : null; + + return ( +
+
+ + { + if (workspaceSheetOpen) { + closeWorkspaceSheet(); + return; + } + setSummaryOpen((open) => !open); + }} + open={summaryVisible} + testId="project-home-drawer-toggle" + > + + + + + + + } + activeTabCrumb={null} + activeWorkItemCrumb={null} + onGoProjectHome={() => undefined} + onGoProjects={() => { + void goProjects(); + }} + project={project} + /> + {waitingForChannel ? ( + + ) : homeChannel ? ( + + } + > + + + ) : ( +
+

+ This project's channel could not be found. +

+
+ )} +
+ + + {summaryVisible ? ( + setSummaryOpen(false)} + onResetWidth={summaryWidth.onResetWidth} + onResizeStart={summaryWidth.onResizeStart} + testId="project-home-summary-column" + title="Overview" + widthPx={summaryWidth.widthPx} + > + { + void goChannel(channelId); + }} + onOpenRepository={handleOpenRepository} + onOpenWorkspace={handleOpenWorkspace} + onRepositoryChange={handleRepositoryChange} + project={project} + projects={projects} + /> + + ) : null} + +
+ ); +} diff --git a/desktop/src/features/projects/ui/ProjectChannelManagement.tsx b/desktop/src/features/projects/ui/ProjectChannelManagement.tsx new file mode 100644 index 00000000000..dbb02fd462c --- /dev/null +++ b/desktop/src/features/projects/ui/ProjectChannelManagement.tsx @@ -0,0 +1,78 @@ +import { Plus } from "lucide-react"; +import * as React from "react"; +import { toast } from "sonner"; + +import { useIsManagedAgent } from "@/features/agent-memory/hooks"; +import { useAppNavigation } from "@/app/navigation/useAppNavigation"; +import { useUsersBatchQuery } from "@/features/profile/hooks"; +import { ownsAuthorAgent } from "@/features/profile/lib/identity"; +import type { Project } from "@/features/projects/hooks"; +import { useAddProjectChannelMutation } from "@/features/projects/useAddProjectChannel"; +import { CreateChannelDialog } from "@/features/sidebar/ui/CreateChannelDialog"; +import { Button } from "@/shared/ui/button"; + +export function ProjectChannelManagement({ + identityPubkey, + project, +}: { + identityPubkey?: string; + project: Project; +}) { + const { goChannel } = useAppNavigation(); + const [createOpen, setCreateOpen] = React.useState(false); + const createMutation = useAddProjectChannelMutation(); + const ownerProfileQuery = useUsersBatchQuery([project.owner], { + enabled: Boolean(identityPubkey), + }); + const projectOwnerProfile = + ownerProfileQuery.data?.profiles[project.owner.toLowerCase()]; + const projectOwnerIsManaged = useIsManagedAgent(project.owner) === true; + const viewerIsProjectOwner = + identityPubkey?.toLowerCase() === project.owner.toLowerCase(); + const viewerOwnsProjectAgent = ownsAuthorAgent( + projectOwnerProfile, + identityPubkey, + ); + const canEdit = + !project.legacy && + (viewerIsProjectOwner || projectOwnerIsManaged || viewerOwnsProjectAgent); + const ownerControlAgentPubkey = + viewerOwnsProjectAgent && !projectOwnerIsManaged && !viewerIsProjectOwner + ? 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" + /> + + + ); +} diff --git a/desktop/src/features/projects/ui/ProjectDetailChrome.tsx b/desktop/src/features/projects/ui/ProjectDetailChrome.tsx index f9740b8aa32..0293d1b2b93 100644 --- a/desktop/src/features/projects/ui/ProjectDetailChrome.tsx +++ b/desktop/src/features/projects/ui/ProjectDetailChrome.tsx @@ -26,8 +26,63 @@ export function ProjectDetailChrome({ onGoProjectHome: () => void; onGoProjects: () => void; project: Project; - repository: Repository; + repository?: Repository | null; }) { + const repositoryCrumb = repository ? ( + activeWorkItemCrumb ? ( + <> + + + + + + {activeWorkItemCrumb.title} + + + ) : activeTabCrumb ? ( + <> + + + + {activeTabCrumb} + + + ) : ( + + {repository.name} + + ) + ) : null; return (
- - - {activeWorkItemCrumb ? ( + {repositoryCrumb ? ( <> - - - - {activeWorkItemCrumb.title} - - - ) : activeTabCrumb ? ( - <> - - - - {activeTabCrumb} - + {repositoryCrumb} ) : ( - {repository.name} + {project.name} )} diff --git a/desktop/src/features/projects/ui/ProjectDetailScreen.tsx b/desktop/src/features/projects/ui/ProjectDetailScreen.tsx index b7f39a8cf33..620edbc2129 100644 --- a/desktop/src/features/projects/ui/ProjectDetailScreen.tsx +++ b/desktop/src/features/projects/ui/ProjectDetailScreen.tsx @@ -45,6 +45,8 @@ import { projectRepoUnavailableReason, refineRepoUnavailableReason, } from "@/features/projects/lib/projectRepoAvailability"; +import { wantsProjectRepositorySurface } from "@/features/projects/lib/projectDetailSearch"; +import { hasAuthoritativeHomeBinding } from "@/features/projects/lib/projectHomeChannel"; import { selectProjectRepository } from "@/features/projects/projectModels"; import { ProjectSelectionProvider } from "@/features/projects/lib/useProjectSelection"; import { useMemberChannelIds } from "@/features/projects/useRepositoryAccess"; @@ -61,6 +63,7 @@ import { ProjectDetailChrome } from "./ProjectDetailChrome"; import { ProjectConversationPanelController } from "./ProjectConversationPanelContext"; import { ProjectDetailRightPanel } from "./ProjectDetailRightPanel"; import { ProjectDetailUnavailableState } from "./ProjectDetailUnavailableState"; +import { ProjectChannelHome } from "./ProjectChannelHome"; import { ProjectRightPanelControls } from "./ProjectRightPanelControls"; import { buildProjectDetailCrumbs } from "./useProjectDetailCrumbs"; import { useProjectDetailPeople } from "./useProjectDetailPeople"; @@ -282,15 +285,17 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { const handleBranchChange = React.useCallback( (branch: string | null) => { selectBranch(branch); + if (!branch) return; + const localBranches = repoSyncStatusQuery.data?.localBranches; if ( - branch && repoSource === "local" && - branch !== repoSyncStatusQuery.data?.localBranch + localBranches && + !localBranches.includes(branch) ) { setRepoSource("remote"); } }, - [repoSource, repoSyncStatusQuery.data?.localBranch, selectBranch], + [repoSource, repoSyncStatusQuery.data?.localBranches, selectBranch], ); const handleTagChange = React.useCallback( (tag: string) => { @@ -673,6 +678,24 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { /> ); } + const showChannelHome = + hasAuthoritativeHomeBinding(project) && + !wantsProjectRepositorySurface({ + commitHash, + issueId, + projectId, + pullRequestId, + repositoryId, + tab, + }); + if (showChannelHome) { + return ( + + ); + } if (!repository) { return ( { + if (project.projectChannelId) { + void goProject(project.id); + return; + } + handleGoToProjectHome(); + }; const agentPageContext = buildProjectDetailAgentContext({ activeTab, branch: activeBranch, @@ -834,7 +864,7 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { actions={repositoryPanelAction} activeTabCrumb={activeTabCrumb} activeWorkItemCrumb={activeWorkItemCrumb} - onGoProjectHome={handleGoToProjectHome} + onGoProjectHome={goChannelHome} onGoProjects={() => { void goProjects(); }} @@ -900,6 +930,7 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { handleSelectedPullRequestIdChange } onSelectedTabChange={setActiveTab} + onBack={goChannelHome} profiles={profiles} project={repository} projectId={project.id} diff --git a/desktop/src/features/projects/ui/ProjectHomeCodebasePanel.tsx b/desktop/src/features/projects/ui/ProjectHomeCodebasePanel.tsx new file mode 100644 index 00000000000..1e883a12b0f --- /dev/null +++ b/desktop/src/features/projects/ui/ProjectHomeCodebasePanel.tsx @@ -0,0 +1,126 @@ +import { ChevronDown, FolderGit2 } from "lucide-react"; + +import { + useProjectRepoSnapshotQuery, + useRepoStateQuery, + type Project, + type Repository, +} from "@/features/projects/hooks"; +import { resolveProjectDefaultBranch } from "@/features/projects/lib/projectBranches"; +import { Button } from "@/shared/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/shared/ui/dropdown-menu"; +import { ProjectRepositoryManagement } from "./ProjectRepositoryManagement"; +import { RepositoryFilesPanel } from "./ProjectRepositoryPanel"; +import { useRepositoryFileContentSource } from "./useRepositoryFileContentSource"; + +export function ProjectHomeCodebasePanel({ + identityPubkey, + onOpenCommit, + onRepositoryAdded, + onSelectRepository, + project, + projects, + repository, +}: { + identityPubkey?: string; + onOpenCommit?: (commitHash: string) => void; + onRepositoryAdded: (repositoryId: string) => void; + onSelectRepository: (repositoryId: string) => void; + project: Project; + projects: Project[]; + repository: Repository | null; +}) { + const repoStateQuery = useRepoStateQuery(repository); + const defaultBranch = repository + ? resolveProjectDefaultBranch(repository.defaultBranch, repoStateQuery.data) + : null; + const snapshotQuery = useProjectRepoSnapshotQuery( + repository, + defaultBranch, + null, + null, + Boolean(repository), + ); + const fileContentSource = useRepositoryFileContentSource({ + activeBranch: defaultBranch, + activeTag: null, + pullRequest: null, + repository, + selectedTag: null, + source: "remote", + }); + const snapshot = snapshotQuery.data ?? null; + const files = snapshot?.files ?? []; + + if (!repository) { + return ( +
+

+ Attach a repository to browse the file tree beside this channel. +

+ +
+ ); + } + + return ( +
+ {project.repositories.length > 1 ? ( +
+ + + + + + {project.repositories.map((candidate) => ( + onSelectRepository(candidate.id)} + > + {candidate.name} + + ))} + + +
+ ) : null} +
+ +
+
+ ); +} diff --git a/desktop/src/features/projects/ui/ProjectHomeColumn.tsx b/desktop/src/features/projects/ui/ProjectHomeColumn.tsx new file mode 100644 index 00000000000..b627ca26ab9 --- /dev/null +++ b/desktop/src/features/projects/ui/ProjectHomeColumn.tsx @@ -0,0 +1,67 @@ +import type * as React from "react"; + +import { RightAuxiliaryPane } from "@/features/channels/ui/RightAuxiliaryPane"; +import { + AuxiliaryPanel, + AuxiliaryPanelBody, + AuxiliaryPanelHeader, + AuxiliaryPanelHeaderGroup, + AuxiliaryPanelHeaderTitleBlock, +} from "@/shared/layout/AuxiliaryPanel"; +import { cn } from "@/shared/lib/cn"; + +export function ProjectHomeColumn({ + bodyClassName, + canResetWidth, + children, + onClose, + onResetWidth, + onResizeStart, + testId, + title, + widthPx, +}: { + bodyClassName?: string; + canResetWidth: boolean; + children: React.ReactNode; + onClose: () => void; + onResetWidth: () => void; + onResizeStart: (event: React.PointerEvent) => void; + testId: string; + title: string; + widthPx: number; +}) { + return ( + +
+ + + + + + } + > + + {children} + + +
+
+ ); +} diff --git a/desktop/src/features/projects/ui/ProjectHomeContextPanel.tsx b/desktop/src/features/projects/ui/ProjectHomeContextPanel.tsx new file mode 100644 index 00000000000..c9ef1d7d5ba --- /dev/null +++ b/desktop/src/features/projects/ui/ProjectHomeContextPanel.tsx @@ -0,0 +1,373 @@ +import { + CircleDot, + FileCode2, + FolderGit2, + GitCommitHorizontal, + GitPullRequest, + Hash, + Users, +} from "lucide-react"; +import type * as React from "react"; + +import { presentContextCount } from "@/features/projects/lib/projectHomeSummary"; +import type { ProjectHomeWorkspaceSheetTab } from "@/features/projects/lib/projectHomeWorkspaceSheet"; +import { resolveProjectDefaultBranch } from "@/features/projects/lib/projectBranches"; +import { listProjectBoundChannels } from "@/features/projects/lib/projectRelatedChannels"; +import { + useProjectActivitySummariesQuery, + useProjectRepoSnapshotQuery, + useRepoStateQuery, + type Project, +} from "@/features/projects/hooks"; +import { ProjectChannelIcon } from "@/features/projects/ui/ProjectChannelIcon"; +import type { Channel } from "@/shared/api/types"; +import { cn } from "@/shared/lib/cn"; +import type { EntityLinkTab } from "@/shared/lib/entityLink"; +import { Button } from "@/shared/ui/button"; +import { ProjectChannelManagement } from "./ProjectChannelManagement"; +import { ProjectRepositoryManagement } from "./ProjectRepositoryManagement"; +import { PROJECT_CONTEXT_ACTION_BUTTON_CLASS } from "./projectContextActionStyles"; + +function ContextSection({ + children, + headerAction, + testId, + title, +}: { + children: React.ReactNode; + headerAction?: React.ReactNode; + testId?: string; + title?: string; +}) { + return ( +
+ {title || headerAction ? ( +
+ {title ? ( +

+ {title} +

+ ) : ( + + )} + {headerAction} +
+ ) : null} + {children} +
+ ); +} + +function ContextRowContent({ + children, + count, + icon, +}: { + children: React.ReactNode; + count?: number; + icon: React.ReactNode; +}) { + return ( + <> + + {icon} + + {children} + + {count ?? ""} + + + ); +} + +function ContextNavButton({ + children, + count, + disabled, + icon, + onClick, + pressed, + testId, + title, +}: { + children: React.ReactNode; + count?: number; + disabled?: boolean; + icon: React.ReactNode; + onClick?: () => void; + pressed?: boolean; + testId?: string; + title?: string; +}) { + return ( + + ); +} + +function ChannelContextRow({ + channel, + onClick, + projectHome, + testId, +}: { + channel: Channel; + onClick?: () => void; + projectHome?: boolean; + testId: string; +}) { + const count = presentContextCount(channel.memberCount); + const Icon = projectHome ? ProjectChannelIcon : Hash; + if (onClick) { + return ( + } + onClick={onClick} + testId={testId} + > + {channel.name} + + ); + } + return ( +
+ }> + {channel.name} + +
+ ); +} + +export function ProjectHomeContextPanel({ + activeWorkspaceTab, + channel, + channels = [], + identityPubkey, + onAddRepository, + onOpenChannel, + onOpenRepository, + onOpenWorkspace, + onRepositoryChange, + project, + projects, +}: { + activeWorkspaceTab?: ProjectHomeWorkspaceSheetTab | null; + channel: Channel | null; + channels?: Channel[]; + identityPubkey?: string; + onAddRepository?: () => void; + onOpenChannel?: (channelId: string) => void; + onOpenRepository: (repositoryId: string) => void; + onOpenWorkspace: (repositoryId: string, tab?: EntityLinkTab) => void; + onRepositoryChange: (repositoryId: string) => void; + project: Project; + projects: Project[]; +}) { + const firstRepository = project.repositories[0] ?? null; + const addRepositoryTitle = firstRepository + ? undefined + : "Add a repository to this project"; + const openWorkspace = (tab: EntityLinkTab) => { + if (firstRepository) { + onOpenWorkspace(firstRepository.id, tab); + return; + } + onAddRepository?.(); + }; + const peopleCount = new Set([ + project.owner, + ...project.repositories.flatMap((repository) => repository.contributors), + ]).size; + const activityQuery = useProjectActivitySummariesQuery([project]); + const activity = activityQuery.data?.[project.id]; + const repoStateQuery = useRepoStateQuery(firstRepository); + const defaultBranch = firstRepository + ? resolveProjectDefaultBranch( + firstRepository.defaultBranch, + repoStateQuery.data, + ) + : null; + const snapshotQuery = useProjectRepoSnapshotQuery( + firstRepository, + defaultBranch, + null, + null, + Boolean(firstRepository), + ); + const channelsById = new Map( + channels.map((candidate) => [candidate.id, candidate]), + ); + const boundChannels = listProjectBoundChannels(project).flatMap((binding) => { + const boundChannel = channelsById.get(binding.channelId); + if (!boundChannel) return []; + return [{ ...binding, channel: boundChannel }]; + }); + const listedChannels = + boundChannels.length > 0 + ? boundChannels + : channel + ? [ + { + channel, + channelId: channel.id, + repositoryId: null, + role: "home" as const, + }, + ] + : []; + + return ( +
+ + } + onClick={() => openWorkspace("issues")} + pressed={activeWorkspaceTab === "issues"} + testId="project-home-context-tasks" + title={addRepositoryTitle} + > + Tasks + + } + onClick={() => openWorkspace("prs")} + pressed={activeWorkspaceTab === "prs"} + testId="project-home-context-reviews" + title={addRepositoryTitle} + > + Reviews + + } + onClick={() => openWorkspace("commits")} + pressed={activeWorkspaceTab === "commits"} + testId="project-home-context-commits" + title={addRepositoryTitle} + > + Commits + + } + onClick={() => openWorkspace("files")} + pressed={activeWorkspaceTab === "files"} + testId="project-home-context-files" + title={addRepositoryTitle} + > + Files + + } + onClick={() => + firstRepository && + onOpenWorkspace(firstRepository.id, "contributors") + } + pressed={activeWorkspaceTab === "contributors"} + testId="project-home-context-people" + title={addRepositoryTitle} + > + People + + + + } + testId="project-home-context-channel" + title="Channels" + > + {listedChannels.length > 0 ? ( + listedChannels.map((binding) => { + const isHome = binding.role === "home"; + return ( + onOpenChannel(binding.channel.id) + } + projectHome={isHome} + testId={ + isHome + ? "project-home-context-home-channel" + : `project-home-context-channel-${binding.channel.name}` + } + /> + ); + }) + ) : ( +

+ }>Unavailable +

+ )} +
+ + } + testId="project-home-context-codebase" + title="Codebase" + > + {project.repositories.length > 0 ? ( + project.repositories.map((repository) => ( + } + key={repository.id} + onClick={() => onOpenRepository(repository.id)} + testId={`project-home-context-repo-${repository.dtag}`} + > + {repository.name} + + )) + ) : ( +

None yet

+ )} +
+
+ ); +} diff --git a/desktop/src/features/projects/ui/ProjectHomeWorkspaceSheet.tsx b/desktop/src/features/projects/ui/ProjectHomeWorkspaceSheet.tsx new file mode 100644 index 00000000000..1449ea84a4d --- /dev/null +++ b/desktop/src/features/projects/ui/ProjectHomeWorkspaceSheet.tsx @@ -0,0 +1,150 @@ +import * as React from "react"; + +import { + useProjectIssuesQuery, + useProjectPullRequestsQuery, + useProjectRepoSnapshotQuery, + useRepoStateQuery, + type Project, +} from "@/features/projects/hooks"; +import { gitContributorPubkeysFromCommits } from "@/features/projects/lib/projectContributorMatching"; +import { resolveProjectDefaultBranch } from "@/features/projects/lib/projectBranches"; +import type { ProjectHomeWorkspaceSheetTab } from "@/features/projects/lib/projectHomeWorkspaceSheet"; +import { ActivityPanel, ContributorsPanel } from "./ProjectDetailFeedPanels"; +import { ProjectHomeCodebasePanel } from "./ProjectHomeCodebasePanel"; +import { ProjectIssuesPanel } from "./ProjectIssuesPanel"; +import { PullRequestsPanel } from "./ProjectPullRequestsPanel"; +import { useProjectDetailPeople } from "./useProjectDetailPeople"; + +export function ProjectHomeWorkspaceSheet({ + identityPubkey, + onOpenCommit, + onRepositoryAdded, + onSelectRepository, + project, + projects, + repository, + tab, +}: { + identityPubkey?: string; + onOpenCommit: (commitHash: string) => void; + onRepositoryAdded: (repositoryId: string) => void; + onSelectRepository: (repositoryId: string) => void; + project: Project; + projects: Project[]; + repository: Project["repositories"][number]; + tab: ProjectHomeWorkspaceSheetTab; +}) { + const [selectedIssueId, setSelectedIssueId] = React.useState( + null, + ); + const [selectedPullRequestId, setSelectedPullRequestId] = React.useState< + string | null + >(null); + + const issuesQuery = useProjectIssuesQuery(repository); + const pullRequestsQuery = useProjectPullRequestsQuery(repository); + const issues = issuesQuery.data ?? []; + const pullRequests = pullRequestsQuery.data ?? []; + const people = useProjectDetailPeople({ + issues, + pullRequests, + repository, + }); + const repoStateQuery = useRepoStateQuery(repository); + const defaultBranch = resolveProjectDefaultBranch( + repository.defaultBranch, + repoStateQuery.data, + ); + const snapshotQuery = useProjectRepoSnapshotQuery( + repository, + defaultBranch, + null, + null, + true, + ); + const snapshot = snapshotQuery.data ?? null; + const contributorPubkeysByGitIdentity = React.useMemo( + () => + gitContributorPubkeysFromCommits(snapshot?.commits ?? [], pullRequests), + [pullRequests, snapshot?.commits], + ); + const selectedPullRequest = + pullRequests.find( + (pullRequest) => pullRequest.id === selectedPullRequestId, + ) ?? null; + + let body: React.ReactNode; + switch (tab) { + case "issues": + body = ( + + ); + break; + case "prs": + body = ( + + ); + break; + case "commits": + body = ( + onOpenCommit(commit.hash)} + profiles={people.profiles} + project={repository} + projectId={project.id} + pullRequests={pullRequests} + repoContributors={snapshot?.contributors ?? []} + snapshot={snapshot} + viewerGitIdentity={people.viewerGitIdentity} + /> + ); + break; + case "files": + body = ( + + ); + break; + case "contributors": + body = ( + + ); + break; + } + + return ( +
+ {body} +
+ ); +} diff --git a/desktop/src/features/projects/ui/ProjectRepositoryManagement.tsx b/desktop/src/features/projects/ui/ProjectRepositoryManagement.tsx index fd08d490511..535c15b0dd7 100644 --- a/desktop/src/features/projects/ui/ProjectRepositoryManagement.tsx +++ b/desktop/src/features/projects/ui/ProjectRepositoryManagement.tsx @@ -23,20 +23,29 @@ import { AttachProjectRepositoryDialog } from "./AttachProjectRepositoryDialog"; export function ProjectRepositoryManagement({ compact = false, + createOpen: createOpenProp, + hideTriggers = false, identityPubkey, onChange, + onCreateOpenChange, project, projects, repository, }: { compact?: boolean; + createOpen?: boolean; + hideTriggers?: boolean; identityPubkey?: string; onChange: (repositoryId: string) => void; + onCreateOpenChange?: (open: boolean) => void; project: Project; projects: Project[]; - repository: Repository; + repository?: Repository | null; }) { - const [createOpen, setCreateOpen] = React.useState(false); + const [uncontrolledCreateOpen, setUncontrolledCreateOpen] = + React.useState(false); + const createOpen = createOpenProp ?? uncontrolledCreateOpen; + const setCreateOpen = onCreateOpenChange ?? setUncontrolledCreateOpen; const [attachOpen, setAttachOpen] = React.useState(false); const channelsQuery = useChannelsQuery(); const createMutation = useAddProjectRepositoryMutation(); @@ -71,18 +80,19 @@ export function ProjectRepositoryManagement({ [channelsQuery.data], ); const inheritedChannelId = [ - repository.channelId, + repository?.channelId, project.projectChannelId, project.repositories.find( - (candidate) => candidate.id !== repository.id && candidate.channelId, + (candidate) => candidate.id !== repository?.id && candidate.channelId, )?.channelId, ].find( (candidate) => candidate && accessChannels.some((channel) => channel.id === candidate), ); const canManageAccess = + Boolean(repository) && accessChannels.length > 0 && - identityPubkey?.toLowerCase() === repository.owner.toLowerCase(); + identityPubkey?.toLowerCase() === repository?.owner.toLowerCase(); const attachCandidates = React.useMemo(() => { const currentAddresses = new Set(project.repositoryAddresses); const candidates = new Map(); @@ -132,7 +142,7 @@ export function ProjectRepositoryManagement({ project={project} repositories={attachCandidates} /> - {canEdit ? ( + {canEdit && !hideTriggers ? ( + + + Files + + + Commits + + + Tasks + + + Review + + + Channels + + + Contributors + + +
); } diff --git a/desktop/src/features/projects/ui/ProjectWorkspaceTabs.tsx b/desktop/src/features/projects/ui/ProjectWorkspaceTabs.tsx index 2c526900d38..64941961231 100644 --- a/desktop/src/features/projects/ui/ProjectWorkspaceTabs.tsx +++ b/desktop/src/features/projects/ui/ProjectWorkspaceTabs.tsx @@ -119,6 +119,7 @@ export function WorkspaceTabs({ onSelectedIssueIdChange, onSelectedPullRequestIdChange, onSelectedTabChange, + onBack, onOpenMergeRecoveryTerminal, snapshot, snapshotError, @@ -170,6 +171,7 @@ export function WorkspaceTabs({ onSelectedPullRequestIdChange: (id: string | null) => void; /** Reports the active tab so the screen breadcrumb can mirror it. */ onSelectedTabChange?: (tab: string) => void; + onBack: () => void; onOpenMergeRecoveryTerminal?: OpenMergeRecoveryTerminal; snapshot: ProjectRepoSnapshot | null | undefined; snapshotError: unknown; @@ -412,7 +414,7 @@ export function WorkspaceTabs({ }`} data-testid="project-workspace-tab-menu" > - +
{updatePullRequestAction ? (