diff --git a/desktop/src/features/channels/ui/ChannelPane.helpers.test.mjs b/desktop/src/features/channels/ui/ChannelPane.helpers.test.mjs index 714b21c5b01..10a948e7053 100644 --- a/desktop/src/features/channels/ui/ChannelPane.helpers.test.mjs +++ b/desktop/src/features/channels/ui/ChannelPane.helpers.test.mjs @@ -3,6 +3,7 @@ import test from "node:test"; import { getChannelIntroKind, + shouldPrioritizeIdleAuxiliary, shouldUseFocusIdleDrawer, } from "./ChannelPane.helpers.ts"; @@ -56,3 +57,9 @@ test("getChannelIntroKind keeps private and ephemeral labels for other streams", "ephemeral channel", ); }); + +test("idle auxiliary priority does not depend on thread layout mode", () => { + assert.equal(shouldPrioritizeIdleAuxiliary(true, true), true); + assert.equal(shouldPrioritizeIdleAuxiliary(true, false), false); + assert.equal(shouldPrioritizeIdleAuxiliary(false, true), false); +}); diff --git a/desktop/src/features/channels/ui/ChannelPane.helpers.ts b/desktop/src/features/channels/ui/ChannelPane.helpers.ts index 8ef2ca91cb5..695fef166fe 100644 --- a/desktop/src/features/channels/ui/ChannelPane.helpers.ts +++ b/desktop/src/features/channels/ui/ChannelPane.helpers.ts @@ -63,6 +63,14 @@ export function getChannelIntroDescription(channel: Channel): string | null { ); } +/** Whether a caller-owned auxiliary sheet should render ahead of a thread. */ +export function shouldPrioritizeIdleAuxiliary( + overrideThread: boolean, + hasIdleAuxiliary: boolean, +) { + return overrideThread && hasIdleAuxiliary; +} + export function isWelcomeSetupSystemMessage(message: TimelineMessage) { if (message.kind !== KIND_SYSTEM_MESSAGE) { return false; diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 531a13724be..679b4c38041 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -47,6 +47,7 @@ import { import { useWelcomeComposerBanner } from "@/features/channels/ui/useWelcomeComposerBanner"; import { mentionsKnownAgent, + shouldPrioritizeIdleAuxiliary, shouldUseFocusIdleDrawer, } from "@/features/channels/ui/ChannelPane.helpers"; import { HuddleStartingView, HuddleTranscriptIntro } from "@/features/huddle"; @@ -84,6 +85,7 @@ export const ChannelPane = React.memo(function ChannelPane({ header, idleAuxiliaryPanel = null, idleAuxiliaryHeaderActions, + idleAuxiliaryOverridesThread = false, idleAuxiliaryTitle = "", hasOlderMessages, historyExhausted, @@ -263,7 +265,6 @@ export const ChannelPane = React.memo(function ChannelPane({ onEdit(target); return true; }, [findLastOwnEditable, messages, onEdit]); - const handleEditLastOwnThreadMessage = React.useCallback((): boolean => { if (!onEdit) return false; const scope: TimelineMessage[] = []; @@ -284,7 +285,6 @@ export const ChannelPane = React.memo(function ChannelPane({ currentPubkey, relaySelfQuery.data, ); - const isComposerDisabled = !activeChannel?.isMember || activeChannel.archivedAt !== null || @@ -294,7 +294,6 @@ export const ChannelPane = React.memo(function ChannelPane({ isSending; const knownAgentPubkeys = React.useMemo(() => { const pubkeys = new Set(); - for (const pubkey of agentPubkeys ?? []) { pubkeys.add(pubkey.toLowerCase()); } @@ -304,7 +303,6 @@ export const ChannelPane = React.memo(function ChannelPane({ for (const agent of activityAgents) { pubkeys.add(agent.pubkey.toLowerCase()); } - return pubkeys; }, [activityAgents, agentPubkeys, agentSessionAgents]); const handleSendMessage = React.useCallback( @@ -323,7 +321,6 @@ export const ChannelPane = React.memo(function ChannelPane({ isActiveWelcomeChannel && (containsWelcomePersonaMention(content) || mentionsKnownAgent(mentionPubkeys, knownAgentPubkeys)); - messageTimelineRef.current?.scrollToBottomOnNextUpdate(); await onSendMessage( content, @@ -333,7 +330,6 @@ export const ChannelPane = React.memo(function ChannelPane({ threadContext, forceRest, ); - if ( channelId && channelId !== activeChannelId && @@ -342,7 +338,6 @@ export const ChannelPane = React.memo(function ChannelPane({ ) { await goChannel(channelId, { replace: true }); } - if (shouldCompleteWelcomeBanner) { completeWelcomeComposerBanner(); } @@ -396,7 +391,6 @@ export const ChannelPane = React.memo(function ChannelPane({ }), [activeChannel, currentPubkey, profiles], ); - const handleWelcomeAddAgent = React.useCallback(() => { onAddAgent?.({ beforeSend: () => @@ -439,7 +433,6 @@ export const ChannelPane = React.memo(function ChannelPane({ for (const message of threadAllMessages) { messagesById.set(message.id, message); } - return buildVideoReviewPresentationByMessageId({ channelId: activeChannel?.id ?? null, channelName: activeChannel?.name, @@ -460,7 +453,6 @@ export const ChannelPane = React.memo(function ChannelPane({ threadAllMessages, threadHeadMessage, ]); - const isOverlay = useIsThreadPanelOverlay(); const useSplitAuxiliaryPane = !isSinglePanelView && !isOverlay; const threadViewMode = useThreadViewMode(); @@ -478,6 +470,8 @@ export const ChannelPane = React.memo(function ChannelPane({ }), [agentSessionAgents, openAgentSessionPubkey, profilePanelPubkey, profiles], ); + const hasIdleAuxiliary = + Boolean(idleAuxiliaryPanel) && Boolean(onCloseIdleAuxiliaryPanel); const useFocusIdleDrawer = shouldUseFocusIdleDrawer({ channelManagementOpen, hasAgentSession: Boolean(activeChannel && selectedAgent), @@ -487,11 +481,17 @@ export const ChannelPane = React.memo(function ChannelPane({ hasThreadSurface: Boolean(threadHeadMessage) || shouldShowThreadSkeleton, useSplitAuxiliaryPane, }); + const priorityIdleAuxiliary = shouldPrioritizeIdleAuxiliary( + idleAuxiliaryOverridesThread, + hasIdleAuxiliary, + ); const { channelIsCovered, markExitComplete } = useFocusDrawerPresence( useFocusThreadDrawer || useFocusIdleDrawer, - useFocusThreadDrawer - ? onCloseThread - : (onCloseIdleAuxiliaryPanel ?? onCloseThread), + priorityIdleAuxiliary + ? (onCloseIdleAuxiliaryPanel ?? onCloseThread) + : useFocusThreadDrawer + ? onCloseThread + : (onCloseIdleAuxiliaryPanel ?? onCloseThread), ); const { changeThreadViewMode, layoutScrollTargetId, resolveScrollTarget } = useThreadViewModeSwitch({ @@ -551,6 +551,25 @@ export const ChannelPane = React.memo(function ChannelPane({ ) : ( wrapAux(panel, "idle-auxiliary-panel") ); + const idleAuxiliarySurface = + idleAuxiliaryPanel && onCloseIdleAuxiliaryPanel + ? wrapIdlePanel( + + {idleAuxiliaryPanel} + , + ) + : null; const threadHeaderLeading = useSplitAuxiliaryPane ? ( ) : undefined; @@ -577,7 +596,6 @@ export const ChannelPane = React.memo(function ChannelPane({ data-testid="channel-shared-header-backdrop" /> ) : null} - {!isSinglePanelView ? (
) : null} - - {/* - * `AnimatePresence` keeps the focus thread drawer mounted through its exit - * animation — without it the drawer's own existence condition - * (`useFocusThreadDrawer`, which is derived from `threadHeadMessage`) goes - * false on the same frame as the close, and there is nothing left to - * animate. It can hold the real thread through the exit rather than a - * frozen snapshot because the panel is fully prop-driven. - */} - + {/* Serialize replacements so focus drawers keep one travel direction. */} + {channelManagementOpen && activeChannel ? ( + ) : priorityIdleAuxiliary && idleAuxiliarySurface ? ( + idleAuxiliarySurface ) : threadHeadMessage ? ( (() => { const panel = ( @@ -975,24 +987,9 @@ export const ChannelPane = React.memo(function ChannelPane({ ); return wrapAux(panel, "user-profile-panel"); })() - ) : idleAuxiliaryPanel && onCloseIdleAuxiliaryPanel ? ( - wrapIdlePanel( - - {idleAuxiliaryPanel} - , - ) - ) : null} + ) : ( + idleAuxiliarySurface + )} ); diff --git a/desktop/src/features/channels/ui/ChannelPane.types.ts b/desktop/src/features/channels/ui/ChannelPane.types.ts index 32951133068..83a9794e5aa 100644 --- a/desktop/src/features/channels/ui/ChannelPane.types.ts +++ b/desktop/src/features/channels/ui/ChannelPane.types.ts @@ -49,11 +49,13 @@ export type ChannelPaneProps = { header?: React.ReactNode; /** * Idle-state body for the right auxiliary pane (project extras, etc.). - * Shown only when no thread, profile, agent session, or channel-management - * panel is open — the same slot as those panels. + * Uses the same slot as thread, profile, agent-session, and management panels. + * By default it yields to those surfaces; callers may opt into thread override. */ idleAuxiliaryPanel?: React.ReactNode; idleAuxiliaryHeaderActions?: IdleAuxiliaryHeaderControls; + /** Show the idle auxiliary surface ahead of an already-open thread. */ + idleAuxiliaryOverridesThread?: boolean; idleAuxiliaryTitle?: string; hasOlderMessages?: boolean; /** True when the loaded window provably starts at the channel's beginning. */ diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index 795647ec2f5..1a92135e9c3 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -91,6 +91,7 @@ export function ChannelScreen({ headerEndActions, idleAuxiliaryPanel, idleAuxiliaryHeaderActions, + idleAuxiliaryOverridesThread, idleAuxiliaryTitle, onAddFiles, onCloseIdleAuxiliaryPanel, @@ -855,6 +856,7 @@ export function ChannelScreen({ header={channelHeader} idleAuxiliaryPanel={idleAuxiliaryPanel} idleAuxiliaryHeaderActions={idleAuxiliaryHeaderActions} + idleAuxiliaryOverridesThread={idleAuxiliaryOverridesThread} idleAuxiliaryTitle={idleAuxiliaryTitle} hasOlderMessages={hasOlderMessages} historyExhausted={historyExhausted} diff --git a/desktop/src/features/channels/ui/ChannelScreen.types.ts b/desktop/src/features/channels/ui/ChannelScreen.types.ts index 33ac2375ce9..e5550667044 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.types.ts +++ b/desktop/src/features/channels/ui/ChannelScreen.types.ts @@ -21,6 +21,7 @@ export type ChannelScreenProps = { currentProfile?: Profile; idleAuxiliaryPanel?: ReactNode; idleAuxiliaryHeaderActions?: IdleAuxiliaryHeaderControls; + idleAuxiliaryOverridesThread?: boolean; idleAuxiliaryTitle?: string; headerEndActions?: ReactNode; onAddFiles?: () => void; diff --git a/desktop/src/features/channels/ui/FocusThreadDrawer.tsx b/desktop/src/features/channels/ui/FocusThreadDrawer.tsx index 7966ccda9b2..b00b612648b 100644 --- a/desktop/src/features/channels/ui/FocusThreadDrawer.tsx +++ b/desktop/src/features/channels/ui/FocusThreadDrawer.tsx @@ -221,7 +221,7 @@ export function FocusThreadDrawer({ // share a radius — a smaller one here would put two radii on one // element. `shadow-panel-left` draws the left edge and its corners; // see the token for why a `border-l` cannot. - "absolute inset-y-0 right-0 flex flex-col overflow-hidden rounded-l-2xl bg-background shadow-panel-left", + "absolute inset-y-0 right-0 flex flex-col overflow-hidden rounded-l-2xl bg-background shadow-panel-left outline-hidden", )} aria-label={label} data-testid="focus-thread-drawer" diff --git a/desktop/src/features/channels/ui/RightAuxiliaryPane.tsx b/desktop/src/features/channels/ui/RightAuxiliaryPane.tsx index d46de8eb7d7..68f68890bbf 100644 --- a/desktop/src/features/channels/ui/RightAuxiliaryPane.tsx +++ b/desktop/src/features/channels/ui/RightAuxiliaryPane.tsx @@ -11,6 +11,7 @@ type RightAuxiliaryPaneProps = { detached?: boolean; onResetWidth: () => void; onResizeStart: (event: React.PointerEvent) => void; + showResizeIndicator?: boolean; testId?: string; widthPx: number; }; @@ -23,6 +24,7 @@ export function RightAuxiliaryPane({ detached = false, onResetWidth, onResizeStart, + showResizeIndicator = true, testId, widthPx, }: RightAuxiliaryPaneProps) { @@ -56,7 +58,12 @@ export function RightAuxiliaryPane({ } type="button" > - + {showResizeIndicator ? ( + + ) : null}
{children} diff --git a/desktop/src/features/projects/createProject.ts b/desktop/src/features/projects/createProject.ts index f11a7905303..8adfe777a96 100644 --- a/desktop/src/features/projects/createProject.ts +++ b/desktop/src/features/projects/createProject.ts @@ -33,6 +33,7 @@ export type CreateProjectInput = { channelVisibility?: ChannelVisibility; projectVisibility?: ProjectListingVisibility; agents?: readonly CreateChannelManagedAgentInput[]; + templateId?: string; }; export type CreateProjectResult = { diff --git a/desktop/src/features/projects/hooks.ts b/desktop/src/features/projects/hooks.ts index 4e26287f65e..1e397c7141e 100644 --- a/desktop/src/features/projects/hooks.ts +++ b/desktop/src/features/projects/hooks.ts @@ -78,11 +78,9 @@ export type { ProjectPullRequestCommentAnchor, Repository, }; - export type ProjectPullRequestCommentDecision = "request-changes"; const HIDDEN_PROJECT_CARDS_KEY = "buzz.projects.hidden-cards.v1"; - export type RepoState = { branches: Array<{ name: string; commit: string }>; tags: Array<{ name: string; commit: string }>; @@ -213,8 +211,10 @@ function eventToRepoState(event: RelayEvent): RepoState { updatedAt: event.created_at, }; } - -async function fetchRepoState(project: Repository): Promise { +/** Load the trusted relay state used to resolve a repository's live refs. */ +export async function fetchRepoState( + project: Repository, +): Promise { const relaySelf = await getRelaySelf(); const trustedAuthors = [ ...new Set( diff --git a/desktop/src/features/projects/lib/projectHomeTemplate.test.mjs b/desktop/src/features/projects/lib/projectHomeTemplate.test.mjs new file mode 100644 index 00000000000..6f6ebfd8f5e --- /dev/null +++ b/desktop/src/features/projects/lib/projectHomeTemplate.test.mjs @@ -0,0 +1,80 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { + applyProjectHomeCanvas, + PROJECT_HOME_CHANNEL_TEMPLATE, + PROJECT_HOME_TEMPLATE_ID, + renderProjectHomeCanvas, +} from "./projectHomeTemplate.ts"; + +test("project home is the built-in default project template", () => { + assert.equal(PROJECT_HOME_CHANNEL_TEMPLATE.id, PROJECT_HOME_TEMPLATE_ID); + assert.equal(PROJECT_HOME_CHANNEL_TEMPLATE.isBuiltin, true); + assert.equal(PROJECT_HOME_CHANNEL_TEMPLATE.name, "Project home"); +}); + +test("project home dispatches its rendered canvas to the created channel", async () => { + const calls = []; + const originalWindow = globalThis.window; + const tauriInternals = { + invoke: async (command, args) => { + calls.push({ command, args }); + return { ok: true, event_id: "event-1" }; + }, + }; + globalThis.window = { __TAURI_INTERNALS__: tauriInternals }; + globalThis.__TAURI_INTERNALS__ = tauriInternals; + try { + const applied = await applyProjectHomeCanvas({ + channelId: "11111111-1111-4111-8111-111111111111", + project: { + id: "30621:owner:space-invaders", + dtag: "space-invaders", + name: "Space Invaders", + owner: "a".repeat(64), + repositories: [], + }, + }); + assert.equal(applied, true); + assert.equal(calls.length, 1); + assert.equal(calls[0].command, "set_canvas"); + assert.equal( + calls[0].args.channelId, + "11111111-1111-4111-8111-111111111111", + ); + assert.match(calls[0].args.content, /# Project Channel: Space Invaders/); + } finally { + globalThis.window = originalWindow; + delete globalThis.__TAURI_INTERNALS__; + } +}); + +test("project home canvas fills project, repository, and channel values", () => { + const content = renderProjectHomeCanvas({ + channelId: "11111111-1111-4111-8111-111111111111", + project: { + id: "30621:owner:space-invaders", + dtag: "space-invaders", + name: "Space Invaders", + owner: "a".repeat(64), + repositories: [ + { + cloneUrls: ["https://relay.example/git/owner/space-invaders"], + dtag: "space-invaders", + owner: "b".repeat(64), + }, + ], + }, + }); + + assert.match(content, /# Project Channel: Space Invaders/); + assert.match(content, /`space-invaders`/); + assert.match(content, /b{64}/); + assert.match(content, /https:\/\/relay\.example\/git\/owner\/space-invaders/); + assert.match(content, /11111111-1111-4111-8111-111111111111/); + assert.equal(content.includes("{{"), false); + assert.match(content, /buzz issues status --issue /); + assert.match(content, /buzz pr open --repo-owner/); + assert.match(content, /buzz canvas set .* --content -/); +}); diff --git a/desktop/src/features/projects/lib/projectHomeTemplate.ts b/desktop/src/features/projects/lib/projectHomeTemplate.ts new file mode 100644 index 00000000000..bb6545466a3 --- /dev/null +++ b/desktop/src/features/projects/lib/projectHomeTemplate.ts @@ -0,0 +1,101 @@ +import { setCanvas } from "@/shared/api/tauri"; +import type { ChannelTemplate } from "@/shared/api/types"; +import type { Project } from "@/features/projects/hooks"; + +export const PROJECT_HOME_TEMPLATE_ID = "builtin:project-home"; + +export const PROJECT_HOME_CANVAS_TEMPLATE = `# Project Channel: {{PROJECT_NAME}} + +This channel is the working home of **{{PROJECT_NAME}}**. + +- Initial repository: \`{{REPO_SLUG}}\` +- Repository owner: \`{{REPO_OWNER_HEX}}\` +- Clone URL: \`{{REPO_CLONE_URL}}\` +- Project channel: \`{{CHANNEL_UUID}}\` + +Everything about this project—decisions, tasks, code review, and releases—happens here, in the open. + +## How to think about this channel + +- **The channel is the project's memory.** If you did it and did not post it, it did not happen. Milestones (picked up, blocked, PR up, merged, done) are top-level posts; details go in threads. +- **Issues are the task queue.** Work starts from an issue. No issue? Create one before you build. +- **The repository is the source of truth for code; the channel is the source of truth for intent.** Read both before acting. +- **One owner per task.** Claim before you build. If it is assigned to someone else, review or unblock—do not duplicate. + +## What you can do here + +| Action | Command | +| --- | --- | +| Inspect the repository | \`buzz repos get --owner {{REPO_OWNER_HEX}} --id {{REPO_SLUG}}\` | +| Create a task | \`buzz issues create --channel {{CHANNEL_UUID}} --title "..." --content -\` | +| Claim or assign a task | \`buzz issues assign --issue --repo-owner {{REPO_OWNER_HEX}} --repo-id {{REPO_SLUG}} --assignee \` | +| Track task state | \`buzz issues status --issue --repo-owner {{REPO_OWNER_HEX}} --repo-id {{REPO_SLUG}} --status open|resolved|closed|draft\` | +| Open a review | \`buzz pr open --repo-owner {{REPO_OWNER_HEX}} --repo-id {{REPO_SLUG}} --subject "..." --body-file - --commit --clone {{REPO_CLONE_URL}} --branch-name --channel {{CHANNEL_UUID}}\` | +| Update a review | \`buzz pr update --repo-owner {{REPO_OWNER_HEX}} --repo-id {{REPO_SLUG}} --pr --pr-author --commit --clone {{REPO_CLONE_URL}}\` | +| Mark a review merged or closed | \`buzz pr status --pr --repo-owner {{REPO_OWNER_HEX}} --repo-id {{REPO_SLUG}} --status merged|closed\` | +| Share files or artifacts | \`buzz upload file --file \` | +| Update this living document | \`buzz canvas set --channel {{CHANNEL_UUID}} --content -\` | + +## Workflow + +1. **Pick up:** Find or create an issue, self-assign it, and post a one-line “picked up” message in the channel. +2. **Build:** Clone or reuse a checkout under \`REPOS/\`. Work on a branch, never the default branch. Follow the repository's configured commit and sign-off policy. +3. **Verify:** Run the fullest relevant test suite before calling anything done. +4. **Ship:** Open a review and post the returned Buzz link verbatim so it renders as a card. Mark the issue resolved when merged. +5. **Report:** @mention whoever delegated the work in the message that delivers the result or blocker—not in acknowledgements. + +## Norms + +- Reply in-thread to continue a topic; use a top-level post for a new topic. Avoid bare acknowledgements. +- @mention only when someone must act; naming someone in narrative does not require an @mention. +- Blocked for more than 30 minutes after honest effort? Post the blocker and what you tried. +- Praise in public; correct the work, not the person. +- Give decisions of record—scope cuts, API choices, and deferrals—their own top-level post so they remain findable. + +Keep this canvas current as the project evolves.`; + +export const PROJECT_HOME_CHANNEL_TEMPLATE: ChannelTemplate = { + id: PROJECT_HOME_TEMPLATE_ID, + name: "Project home", + description: null, + channelType: "stream", + visibility: "open", + canvasTemplate: PROJECT_HOME_CANVAS_TEMPLATE, + agents: { personas: [], teams: [] }, + isBuiltin: true, + createdAt: "", + updatedAt: "", +}; + +export function renderProjectHomeCanvas(input: { + channelId: string; + project: Project; +}) { + const repository = input.project.repositories[0]; + const values: Record = { + CHANNEL_UUID: input.channelId, + PROJECT_NAME: input.project.name, + REPO_CLONE_URL: repository?.cloneUrls[0] ?? "Unavailable", + REPO_OWNER_HEX: repository?.owner ?? input.project.owner, + REPO_SLUG: repository?.dtag ?? input.project.dtag, + }; + return Object.entries(values).reduce( + (content, [key, value]) => content.replaceAll(`{{${key}}}`, value), + PROJECT_HOME_CANVAS_TEMPLATE, + ); +} + +export async function applyProjectHomeCanvas(input: { + channelId: string; + project: Project; +}) { + try { + await setCanvas({ + channelId: input.channelId, + content: renderProjectHomeCanvas(input), + }); + return true; + } catch { + return false; + } +} diff --git a/desktop/src/features/projects/projectWorkItems.test.mjs b/desktop/src/features/projects/projectWorkItems.test.mjs index 21ee577b54e..d4060e130a9 100644 --- a/desktop/src/features/projects/projectWorkItems.test.mjs +++ b/desktop/src/features/projects/projectWorkItems.test.mjs @@ -1,7 +1,10 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { fetchProjectsWorkItems } from "./projectWorkItems.ts"; +import { + fetchProjectsWorkItems, + projectsWithWorkItemRepositories, +} from "./projectWorkItems.ts"; // ── Work-item deduplication ───────────────────────────────────────────────── // @@ -27,8 +30,31 @@ const projectB = { repositories: [{ repoAddress: REPO_ADDRESS }], }; +test("work-item scope keeps explicit and repository-only read models", () => { + const explicitProject = { + id: "explicit", + legacy: false, + repositories: [{ repoAddress: REPO_ADDRESS }], + }; + const repositoryOnlyProject = { + id: "repository-only", + legacy: true, + repositories: [{ repoAddress: `30617:${REPO_OWNER}:standalone` }], + }; + const emptyProject = { id: "empty", legacy: false, repositories: [] }; + + assert.deepEqual( + projectsWithWorkItemRepositories([ + explicitProject, + repositoryOnlyProject, + emptyProject, + ]).map((project) => project.id), + ["explicit", "repository-only"], + ); +}); + // Minimal valid NIP-34 issue event for the shared repo. -function makeIssue(id, updatedAt = 100) { +function makeIssue(id, updatedAt = 100, repoAddress = REPO_ADDRESS) { return { id, kind: 1621, @@ -36,12 +62,34 @@ function makeIssue(id, updatedAt = 100) { created_at: updatedAt, content: "An issue", tags: [ - ["a", REPO_ADDRESS], + ["a", repoAddress], ["subject", "Fix the thing"], ], }; } +test("fetchProjectsWorkItems accumulates issues from every project repository", async () => { + const secondAddress = `30617:${REPO_OWNER}:desktop`; + const project = { + repositories: [ + { repoAddress: REPO_ADDRESS }, + { repoAddress: secondAddress }, + ], + }; + const result = await fetchProjectsWorkItems( + [project], + makeFetchEvents([ + makeIssue(ISSUE_ID, 100, REPO_ADDRESS), + makeIssue("j".repeat(64), 90, secondAddress), + ]), + ); + + assert.deepEqual( + result.issues.items.map(({ repository }) => repository.repoAddress).sort(), + [REPO_ADDRESS, secondAddress].sort(), + ); +}); + // Minimal valid NIP-34 pull request event for the shared repo. function makePR(id, updatedAt = 100) { return { diff --git a/desktop/src/features/projects/projectWorkItems.ts b/desktop/src/features/projects/projectWorkItems.ts index 11acbc8b34b..d6dfd49a865 100644 --- a/desktop/src/features/projects/projectWorkItems.ts +++ b/desktop/src/features/projects/projectWorkItems.ts @@ -62,6 +62,13 @@ export type ProjectsWorkItemsResult = { }; }; +/** Includes every repository-bearing read model, including repository-only ones. */ +export function projectsWithWorkItemRepositories< + TProject extends ProjectReference, +>(projects: readonly TProject[]): TProject[] { + return projects.filter((project) => project.repositories.length > 0); +} + function groupByRepoAddress(events: RelayEvent[]): Map { const grouped = new Map(); for (const event of events) { diff --git a/desktop/src/features/projects/ui/AddProjectRepositoryDialog.tsx b/desktop/src/features/projects/ui/AddProjectRepositoryDialog.tsx index 609c38a2cc6..e47aceb2a13 100644 --- a/desktop/src/features/projects/ui/AddProjectRepositoryDialog.tsx +++ b/desktop/src/features/projects/ui/AddProjectRepositoryDialog.tsx @@ -22,6 +22,7 @@ export function AddProjectRepositoryDialog({ onOpenChange, open, project, + projects, }: { accessChannelId?: string; channels: Channel[]; @@ -29,37 +30,51 @@ export function AddProjectRepositoryDialog({ onAdd: (input: AddProjectRepositoryInput) => Promise; onOpenChange: (open: boolean) => void; open: boolean; - project: Project; + project?: Project; + projects?: Project[]; }) { + const projectOptions = React.useMemo( + () => projects ?? (project ? [project] : []), + [project, projects], + ); + const [selectedProjectId, setSelectedProjectId] = React.useState( + project?.id ?? projectOptions[0]?.id ?? "", + ); + const selectedProject = + projectOptions.find((candidate) => candidate.id === selectedProjectId) ?? + projectOptions[0]; const [name, setName] = React.useState(""); const [cloneUrl, setCloneUrl] = React.useState(""); const [selectedChannelId, setSelectedChannelId] = React.useState(""); const [errorMessage, setErrorMessage] = React.useState(null); const nameInputRef = React.useRef(null); + const projectSelectRef = React.useRef(null); React.useEffect(() => { if (!open) return; setName(""); setCloneUrl(""); + setSelectedProjectId(project?.id ?? projectOptions[0]?.id ?? ""); setSelectedChannelId(accessChannelId ?? ""); setErrorMessage(null); const timerId = globalThis.setTimeout( - () => nameInputRef.current?.focus(), + () => + (projects ? projectSelectRef.current : nameInputRef.current)?.focus(), 50, ); return () => globalThis.clearTimeout(timerId); - }, [accessChannelId, open]); + }, [accessChannelId, open, project?.id, projectOptions, projects]); async function handleSubmit(event: React.FormEvent) { event.preventDefault(); - if (!name.trim() || !selectedChannelId) return; + if (!name.trim() || !selectedChannelId || !selectedProject) return; setErrorMessage(null); try { await onAdd({ accessChannelId: selectedChannelId, cloneUrl: cloneUrl.trim() || undefined, name: name.trim(), - project, + project: selectedProject, }); onOpenChange(false); } catch (error) { @@ -81,11 +96,20 @@ export function AddProjectRepositoryDialog({ className="max-w-lg" contentClassName="pt-3" data-testid="add-project-repository-dialog" - description={`Add another repository to ${project.name}.`} + description={ + selectedProject + ? `Add another repository to ${selectedProject.name}.` + : "Choose a project for this repository." + } footer={ + + + + handleTemplateChange(value === NO_TEMPLATE_VALUE ? "" : value) + } + value={templateId || NO_TEMPLATE_VALUE} + > + + None + + {templates.map((template) => ( + + {template.name} + + ))} + + + setIsCreateTemplateOpen(true)}> + + Create new channel template… + + + + +
+ +
+ + Team + + Optional + + + + + + + + + setTeamId(value === NONE_TEAM_VALUE ? "" : value) + } + value={teamId || NONE_TEAM_VALUE} + > + + None + + {teams.map((team) => ( + + {team.name} + + ))} + + + +
+
Project list diff --git a/desktop/src/features/projects/ui/ProjectChannelHome.tsx b/desktop/src/features/projects/ui/ProjectChannelHome.tsx index 84c4295cfd8..d7b0d766fad 100644 --- a/desktop/src/features/projects/ui/ProjectChannelHome.tsx +++ b/desktop/src/features/projects/ui/ProjectChannelHome.tsx @@ -134,6 +134,12 @@ export function ProjectChannelHome({ null; const workspaceSheetOpen = workspaceSheetTab != null && workspaceRepository != null; + const previousWorkspaceSheetOpenRef = React.useRef(workspaceSheetOpen); + const workspaceSheetVisibilityChanged = + previousWorkspaceSheetOpenRef.current !== workspaceSheetOpen; + React.useEffect(() => { + previousWorkspaceSheetOpenRef.current = workspaceSheetOpen; + }, [workspaceSheetOpen]); const summaryVisible = summaryOpen && !workspaceSheetOpen; const openWorkspaceSheet = React.useCallback( @@ -203,8 +209,8 @@ export function ProjectChannelHome({ const handleExpandWorkspace = React.useCallback(() => { if (!workspaceRepository || !workspaceSheetTab) return; void goProject(project.id, { - ...workspaceDetail?.navigation, repositoryId: workspaceRepository.id, + ...workspaceDetail?.navigation, tab: projectHomeWorkspaceSheetExpandTab(workspaceSheetTab), }); }, [ @@ -352,6 +358,7 @@ export function ProjectChannelHome({ backLabel: workspaceDetail?.backLabel, onBack: workspaceDetail?.onBack, }} + idleAuxiliaryOverridesThread={workspaceSheetOpen} idleAuxiliaryTitle={ workspaceSheetTab ? projectHomeWorkspaceSheetTitle(workspaceSheetTab) @@ -389,6 +396,7 @@ export function ProjectChannelHome({ projects={projects} /> ; snapshot: ProjectRepoSnapshot | null | undefined; isLoading: boolean; error: unknown; - onSelectCommit?: (commit: ProjectRepoCommit) => void; + onSelectCommit?: (commit: ProjectRepoCommit, project: Repository) => void; profiles?: UserProfileLookup; project: Repository; projectId: string; @@ -259,21 +269,33 @@ export function ActivityPanel({ repoContributors: ProjectRepoContributor[]; viewerGitIdentity?: ViewerGitIdentity | null; }) { - const commits = snapshot?.commits ?? []; - const commitAuthorPubkeys = commitAuthorPubkeysFromPullRequests( - pullRequests ?? [], - ); - const rangeItems = commits.map((commit) => { - const matchedProfile = profileForCommit( + const items = + commitItems ?? + (snapshot?.commits ?? []).map((commit) => ({ + branch, commit, + project, + projectId, + pullRequests, + repoContributors, + })); + const showRepositoryName = + commitItems !== undefined && + new Set(items.map((item) => item.project.repoAddress)).size > 1; + const rangeItems = items.map((item) => { + const commitAuthorPubkeys = commitAuthorPubkeysFromPullRequests( + item.pullRequests ?? [], + ); + const matchedProfile = profileForCommit( + item.commit, profiles, commitAuthorPubkeys, viewerGitIdentity, ); return commitSelectionItem( - commit, - project, - projectId, + item.commit, + item.project, + item.projectId, matchedProfile?.pubkey, ); }); @@ -282,13 +304,15 @@ export function ActivityPanel({ return ; } - if (commits.length === 0) { + if (items.length === 0) { return (
- {commits.map((commit) => { + {items.map((item) => { + const commitAuthorPubkeys = commitAuthorPubkeysFromPullRequests( + item.pullRequests ?? [], + ); const matchedProfile = profileForCommit( - commit, + item.commit, profiles, commitAuthorPubkeys, viewerGitIdentity, @@ -311,36 +338,51 @@ export function ActivityPanel({ pubkey: matchedProfile.pubkey, profiles, }) - : commit.authorName || commit.authorEmail || "Unknown author"; - const matchingContributor = repoContributors.find( + : item.commit.authorName || + item.commit.authorEmail || + "Unknown author"; + const matchingContributor = (item.repoContributors ?? []).find( (contributor) => contributor.name.trim().toLowerCase() === - commit.authorName.trim().toLowerCase() || + item.commit.authorName.trim().toLowerCase() || contributor.email.trim().toLowerCase() === - commit.authorEmail.trim().toLowerCase(), + item.commit.authorEmail.trim().toLowerCase(), ); return ( - - {branch} + {showRepositoryName ? ( + <> + + {item.project.name} + + ) : ( + <> + + {item.branch} + + )} ) : undefined } - onOpen={onSelectCommit ? () => onSelectCommit(commit) : undefined} + onOpen={ + onSelectCommit + ? () => onSelectCommit(item.commit, item.project) + : undefined + } selection={{ item: commitSelectionItem( - commit, - project, - projectId, + item.commit, + item.project, + item.projectId, matchedProfile?.pubkey, ), rangeItems, @@ -349,7 +391,7 @@ export function ActivityPanel({ } testId="project-activity-feed-item" - title={commit.subject} + title={item.commit.subject} trailing={ <> - {relativeTime(commit.timestamp)} + {relativeTime(item.commit.timestamp)} } diff --git a/desktop/src/features/projects/ui/ProjectHomeColumn.tsx b/desktop/src/features/projects/ui/ProjectHomeColumn.tsx index 5b911a1367b..628f0988e55 100644 --- a/desktop/src/features/projects/ui/ProjectHomeColumn.tsx +++ b/desktop/src/features/projects/ui/ProjectHomeColumn.tsx @@ -1,12 +1,7 @@ import type * as React from "react"; import { RightAuxiliaryPane } from "@/features/channels/ui/RightAuxiliaryPane"; -import { - AuxiliaryPanelBody, - AuxiliaryPanelHeader, - AuxiliaryPanelHeaderGroup, - AuxiliaryPanelHeaderTitleBlock, -} from "@/shared/layout/AuxiliaryPanel"; +import { AuxiliaryPanelBody } from "@/shared/layout/AuxiliaryPanel"; import { cn } from "@/shared/lib/cn"; export function ProjectHomeColumn({ @@ -16,7 +11,6 @@ export function ProjectHomeColumn({ onResetWidth, onResizeStart, testId, - title, widthPx, }: { bodyClassName?: string; @@ -25,7 +19,6 @@ export function ProjectHomeColumn({ onResetWidth: () => void; onResizeStart: (event: React.PointerEvent) => void; testId: string; - title: string; widthPx: number; }) { return ( @@ -36,17 +29,11 @@ export function ProjectHomeColumn({ detached onResetWidth={onResetWidth} onResizeStart={onResizeStart} + showResizeIndicator={false} testId={testId} widthPx={widthPx} >
- - -
- -
-
-
", { + url: "http://localhost", +}); + +before(() => { + Object.assign(globalThis, { + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + IS_REACT_ACT_ENVIRONMENT: true, + window: dom.window, + }); +}); + +after(() => dom.window.close()); + +function repository(id, name) { + return { + id, + name, + repoAddress: `30617:owner:${id}`, + defaultBranch: "main", + }; +} + +test("multi-repository commits remain visibly degraded when one repository fails", async () => { + const { cleanup, render, screen } = await import("@testing-library/react"); + const { ProjectHomeCommitsPanel } = await import( + "./ProjectHomeCommitsPanel.tsx" + ); + const loadedRepository = repository("loaded", "Loaded"); + const failedRepository = repository("failed", "Failed"); + + const React = await import("react"); + try { + render( + React.createElement(ProjectHomeCommitsPanel, { + onSelectCommit: () => {}, + projectId: "project-1", + pullRequests: [], + results: [ + { + error: null, + isLoading: false, + repository: loadedRepository, + snapshot: { + contributors: [], + commits: [ + { + hash: "a".repeat(40), + shortHash: "aaaaaaa", + authorName: "Alice", + authorEmail: "alice@example.com", + timestamp: 2, + subject: "Loaded commit", + }, + ], + }, + }, + { + error: new Error("unavailable"), + isLoading: false, + repository: failedRepository, + snapshot: null, + }, + ], + }), + ); + + assert.match( + screen.getByTestId("project-home-commits-degraded").textContent, + /Showing commits from 1 of 2 repositories/, + ); + assert.match(document.body.textContent, /Loaded commit/); + } finally { + cleanup(); + } +}); + +test("multi-repository commits are merged in descending timestamp order", async () => { + const { cleanup, render } = await import("@testing-library/react"); + const { ProjectHomeCommitsPanel } = await import( + "./ProjectHomeCommitsPanel.tsx" + ); + const React = await import("react"); + const result = (id, subject, timestamp) => ({ + error: null, + isLoading: false, + repository: repository(id, id), + snapshot: { + contributors: [], + commits: [ + { + hash: id.repeat(40), + shortHash: id.repeat(7), + authorName: id, + authorEmail: `${id}@example.com`, + timestamp, + subject, + }, + ], + }, + }); + + try { + render( + React.createElement(ProjectHomeCommitsPanel, { + onSelectCommit: () => {}, + projectId: "project-1", + pullRequests: [], + results: [ + result("a", "Older commit", 1), + result("b", "Newer commit", 2), + ], + }), + ); + + assert.ok( + document.body.textContent.indexOf("Newer commit") < + document.body.textContent.indexOf("Older commit"), + ); + } finally { + cleanup(); + } +}); diff --git a/desktop/src/features/projects/ui/ProjectHomeCommitsPanel.tsx b/desktop/src/features/projects/ui/ProjectHomeCommitsPanel.tsx new file mode 100644 index 00000000000..9876c0fcbb2 --- /dev/null +++ b/desktop/src/features/projects/ui/ProjectHomeCommitsPanel.tsx @@ -0,0 +1,107 @@ +import type { ProjectPullRequest, Repository } from "@/features/projects/hooks"; +import { AlertTriangle } from "lucide-react"; +import { + projectRepoUnavailablePresentation, + projectRepoUnavailableReason, +} from "@/features/projects/lib/projectRepoAvailability"; +import type { ViewerGitIdentity } from "@/features/projects/lib/projectContributorMatching"; +import type { ProjectRepositorySnapshotResult } from "@/features/projects/useProjectRepositorySnapshots"; +import type { UserProfileLookup } from "@/features/profile/lib/identity"; +import type { ProjectRepoCommit } from "@/shared/api/types"; +import { BuzzLoadingState } from "@/shared/ui/BuzzLoadingState"; +import { ProjectPanelState } from "./ProjectPanelState"; +import { ActivityPanel } from "./ProjectDetailFeedPanels"; + +export function ProjectHomeCommitsPanel({ + onSelectCommit, + profiles, + projectId, + pullRequests, + results, + viewerGitIdentity, +}: { + onSelectCommit: (commit: ProjectRepoCommit, repository: Repository) => void; + profiles?: UserProfileLookup; + projectId: string; + pullRequests: ProjectPullRequest[]; + results: ProjectRepositorySnapshotResult[]; + viewerGitIdentity?: ViewerGitIdentity | null; +}) { + const loaded = results.filter( + (result) => (result.snapshot?.commits.length ?? 0) > 0, + ); + const commitItems = loaded + .flatMap(({ repository, snapshot }) => + (snapshot?.commits ?? []).map((commit) => ({ + branch: repository.defaultBranch, + commit, + project: repository, + projectId, + pullRequests, + repoContributors: snapshot?.contributors ?? [], + })), + ) + .sort((left, right) => right.commit.timestamp - left.commit.timestamp); + const failed = results.filter((result) => result.error); + const firstFailure = failed[0]; + const failure = firstFailure + ? projectRepoUnavailablePresentation( + projectRepoUnavailableReason(firstFailure.error), + ) + : null; + if (results.some((result) => result.isLoading) && loaded.length === 0) { + return ; + } + if (loaded.length === 0) { + return ( + 1 + ? ` ${failed.length - 1} other repositories also failed.` + : "" + }` + : "Commits pushed to this project's repositories will appear here." + } + error={failed.length > 0} + title={failure?.title ?? "No commits yet"} + /> + ); + } + + const firstItem = commitItems[0]; + if (!firstItem) return null; + return ( +
+ {failed.length > 0 ? ( +
+ +

+ Showing commits from {loaded.length} of {results.length}{" "} + repositories. {failed.length}{" "} + {failed.length === 1 ? "repository could" : "repositories could"}{" "} + not be loaded. +

+
+ ) : null} + +
+ ); +} diff --git a/desktop/src/features/projects/ui/ProjectHomeContextPanel.tsx b/desktop/src/features/projects/ui/ProjectHomeContextPanel.tsx index 0b76ccbbd31..63245ab632c 100644 --- a/desktop/src/features/projects/ui/ProjectHomeContextPanel.tsx +++ b/desktop/src/features/projects/ui/ProjectHomeContextPanel.tsx @@ -1,4 +1,5 @@ import { + ChevronDown, CircleDot, FileCode2, FolderGit2, @@ -7,7 +8,7 @@ import { Hash, Users, } from "lucide-react"; -import type * as React from "react"; +import * as React from "react"; import { presentContextCount } from "@/features/projects/lib/projectHomeSummary"; import type { ProjectHomeWorkspaceSheetTab } from "@/features/projects/lib/projectHomeWorkspaceSheet"; @@ -26,36 +27,60 @@ import type { EntityLinkTab } from "@/shared/lib/entityLink"; import { Button } from "@/shared/ui/button"; import { ProjectChannelManagement } from "./ProjectChannelManagement"; import { ProjectRepositoryManagement } from "./ProjectRepositoryManagement"; +import { SECTION_ACTION_VISIBILITY_CLASS } from "@/features/sidebar/ui/sidebarSectionStyles"; const PROJECT_HOME_SIDEBAR_ROW_CLASS = "h-8 w-full justify-start gap-2 rounded-md px-2 py-1.5 text-left text-sm font-normal text-sidebar-foreground/80 transition-[background-color,color] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50"; function ContextSection({ children, + collapsible = false, headerAction, testId, title, }: { children: React.ReactNode; + collapsible?: boolean; headerAction?: React.ReactNode; testId?: string; title?: string; }) { + const [expanded, setExpanded] = React.useState(true); return ( -
+
{title || headerAction ? (
- {title ? ( + {title && collapsible ? ( + + ) : title ? (

{title}

) : ( )} - {headerAction} + {headerAction ? ( + + {headerAction} + + ) : null}
) : null} - {children} + {!collapsible || expanded ? children : null}
); } @@ -297,6 +322,7 @@ export function ProjectHomeContextPanel({ { + const detailPanel = source.match(//)?.[0]; + + assert.ok(detailPanel, "expected the commit detail panel to be rendered"); + assert.match(detailPanel, /project=\{selectedCommitRepository\}/); +}); diff --git a/desktop/src/features/projects/ui/ProjectHomeWorkspaceSheet.tsx b/desktop/src/features/projects/ui/ProjectHomeWorkspaceSheet.tsx index 6562115d258..eca3cb6a614 100644 --- a/desktop/src/features/projects/ui/ProjectHomeWorkspaceSheet.tsx +++ b/desktop/src/features/projects/ui/ProjectHomeWorkspaceSheet.tsx @@ -1,28 +1,25 @@ import * as React from "react"; -import { toast } from "sonner"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { useCommunities } from "@/features/communities/useCommunities"; import { - useProjectIssuesQuery, useProjectPullRequestsQuery, useProjectRepoSnapshotQuery, + useProjectsWorkItemsQuery, useRepoStateQuery, type Project, } from "@/features/projects/hooks"; -import { useCreateProjectIssueMutation } from "@/features/projects/issueMutations"; import { gitContributorPubkeysFromCommits } from "@/features/projects/lib/projectContributorMatching"; import { resolveProjectDefaultBranch } from "@/features/projects/lib/projectBranches"; import type { ProjectHomeWorkspaceSheetTab } from "@/features/projects/lib/projectHomeWorkspaceSheet"; import { useProjectCommitDiffQuery } from "@/features/projects/useProjectCommitDiff"; -import { - CreateIssueDialog, - type CreateIssueDialogInput, -} from "./CreateIssueDialog"; +import { useProjectRepositorySnapshots } from "@/features/projects/useProjectRepositorySnapshots"; +import { CreateProjectIssueDialog } from "./CreateProjectIssueDialog"; import { CreatePullRequestDialog } from "./CreatePullRequestDialog"; import { ProjectCommitDetailPanel } from "./ProjectCommitDetailPanel"; -import { ActivityPanel, ContributorsPanel } from "./ProjectDetailFeedPanels"; +import { ContributorsPanel } from "./ProjectDetailFeedPanels"; import { ProjectHomeCodebasePanel } from "./ProjectHomeCodebasePanel"; +import { ProjectHomeCommitsPanel } from "./ProjectHomeCommitsPanel"; import { ProjectIssuesPanel } from "./ProjectIssuesPanel"; import { PullRequestsPanel } from "./ProjectPullRequestsPanel"; import { PROJECT_DETAIL_PANEL_CLASS } from "./projectPanelStyles"; @@ -42,6 +39,7 @@ export type ProjectHomeWorkspaceDetail = { filePath?: string; issueId?: string; pullRequestId?: string; + repositoryId?: string; }; onBack: () => void; }; @@ -82,6 +80,8 @@ export function ProjectHomeWorkspaceSheet({ const [selectedCommitHash, setSelectedCommitHash] = React.useState< string | null >(null); + const [selectedCommitRepositoryId, setSelectedCommitRepositoryId] = + React.useState(null); const [filesContext, setFilesContext] = React.useState<{ kind: "file" | "folder"; onBack?: () => void; @@ -91,9 +91,23 @@ export function ProjectHomeWorkspaceSheet({ const [createPullRequestOpen, setCreatePullRequestOpen] = React.useState(false); - const issuesQuery = useProjectIssuesQuery(repository); + const projectScope = React.useMemo(() => [project], [project]); + const workItemsQuery = useProjectsWorkItemsQuery(projectScope); const pullRequestsQuery = useProjectPullRequestsQuery(repository); - const issues = issuesQuery.data ?? []; + const issueItems = React.useMemo( + () => + (workItemsQuery.data?.issues.items ?? []).map( + ({ issue, repository: issueRepository }) => ({ + issue, + project: issueRepository, + }), + ), + [workItemsQuery.data?.issues.items], + ); + const issues = React.useMemo( + () => issueItems.map(({ issue }) => issue), + [issueItems], + ); const pullRequests = pullRequestsQuery.data ?? []; const people = useProjectDetailPeople({ issues, @@ -113,13 +127,23 @@ export function ProjectHomeWorkspaceSheet({ true, ); const snapshot = snapshotQuery.data ?? null; + const repositorySnapshots = useProjectRepositorySnapshots( + project.repositories, + tab === "commits", + ); + const selectedCommitResult = + repositorySnapshots.find( + ({ repository: candidate }) => + candidate.id === selectedCommitRepositoryId, + ) ?? null; + const selectedCommitRepository = + selectedCommitResult?.repository ?? repository; const commitDiffQuery = useProjectCommitDiffQuery( - repository, + selectedCommitRepository, selectedCommitHash, "remote", activeCommunity?.reposDir, ); - const createIssueMutation = useCreateProjectIssueMutation(repository); const contributorPubkeysByGitIdentity = React.useMemo( () => gitContributorPubkeysFromCommits(snapshot?.commits ?? [], pullRequests), @@ -129,24 +153,37 @@ export function ProjectHomeWorkspaceSheet({ pullRequests.find( (pullRequest) => pullRequest.id === selectedPullRequestId, ) ?? null; + const selectedIssueItem = + issueItems.find(({ issue }) => issue.id === selectedIssueId) ?? null; const selectedCommit = + selectedCommitResult?.snapshot?.commits.find( + (commit) => commit.hash === selectedCommitHash, + ) ?? snapshot?.commits.find((commit) => commit.hash === selectedCommitHash) ?? null; const selectedCommitPullRequest = selectedCommitHash - ? pullRequests.find( - (pullRequest) => - pullRequest.commit === selectedCommitHash || - pullRequest.initialCommit === selectedCommitHash, - ) + ? selectedCommitRepository.id === repository.id + ? pullRequests.find( + (pullRequest) => + pullRequest.commit === selectedCommitHash || + pullRequest.initialCommit === selectedCommitHash, + ) + : null : null; - const handleCreateIssue = React.useCallback( - async (input: CreateIssueDialogInput) => { - const issueId = await createIssueMutation.mutateAsync(input); - toast.success("Task created."); - await issuesQuery.refetch(); + const handleIssueCreated = React.useCallback( + async ( + createdProject: Project, + _createdRepository: Project["repositories"][number], + issueId: string, + ) => { + if (createdProject.id !== project.id) { + await goProject(createdProject.id, { issueId }); + return; + } + await workItemsQuery.refetch(); setSelectedIssueId(issueId); }, - [createIssueMutation, issuesQuery], + [goProject, project.id, workItemsQuery], ); const handlePullRequestCreated = React.useCallback( async ( @@ -179,7 +216,10 @@ export function ProjectHomeWorkspaceSheet({ if (tab === "issues" && selectedIssueId) { return { backLabel: "Back to Tasks", - navigation: { issueId: selectedIssueId }, + navigation: { + issueId: selectedIssueId, + repositoryId: selectedIssueItem?.project.id, + }, onBack: () => setSelectedIssueId(null), }; } @@ -193,8 +233,14 @@ export function ProjectHomeWorkspaceSheet({ if (tab === "commits" && selectedCommitHash) { return { backLabel: "Back to Commits", - navigation: { commitHash: selectedCommitHash }, - onBack: () => setSelectedCommitHash(null), + navigation: { + commitHash: selectedCommitHash, + repositoryId: selectedCommitRepository.id, + }, + onBack: () => { + setSelectedCommitHash(null); + setSelectedCommitRepositoryId(null); + }, }; } if (tab === "files" && filesContext?.onBack) { @@ -208,7 +254,9 @@ export function ProjectHomeWorkspaceSheet({ }, [ filesContext, selectedCommitHash, + selectedCommitRepository.id, selectedIssueId, + selectedIssueItem?.project.id, selectedPullRequestId, tab, ]); @@ -224,7 +272,7 @@ export function ProjectHomeWorkspaceSheet({ React.useEffect(() => { if (tab === "issues" && !selectedIssueId) { onCreateActionChange?.({ - disabled: createIssueMutation.isPending, + disabled: project.repositories.length === 0, label: "Create task", onClick: () => setCreateIssueOpen(true), }); @@ -241,8 +289,8 @@ export function ProjectHomeWorkspaceSheet({ } onCreateActionChange?.(null); }, [ - createIssueMutation.isPending, onCreateActionChange, + project.repositories.length, projects.length, selectedIssueId, selectedPullRequestId, @@ -260,9 +308,12 @@ export function ProjectHomeWorkspaceSheet({ case "issues": body = ( ); @@ -290,20 +341,18 @@ export function ProjectHomeWorkspaceSheet({ diffLoading={commitDiffQuery.isLoading} originAgentName={selectedCommitPullRequest?.originAgentName} originChannelId={selectedCommitPullRequest?.channelId} - project={repository} + project={selectedCommitRepository} /> ) : ( - setSelectedCommitHash(commit.hash)} + { + setSelectedCommitRepositoryId(commitRepository.id); + setSelectedCommitHash(commit.hash); + }} profiles={people.profiles} - project={repository} projectId={project.id} pullRequests={pullRequests} - repoContributors={snapshot?.contributors ?? []} - snapshot={snapshot} + results={repositorySnapshots} viewerGitIdentity={people.viewerGitIdentity} /> ); @@ -362,12 +411,12 @@ export function ProjectHomeWorkspaceSheet({ reposDir={activeCommunity?.reposDir} /> ) : null} -
); diff --git a/desktop/src/features/projects/ui/ProjectIssuesPanel.tsx b/desktop/src/features/projects/ui/ProjectIssuesPanel.tsx index 1142a499bda..21fc916db04 100644 --- a/desktop/src/features/projects/ui/ProjectIssuesPanel.tsx +++ b/desktop/src/features/projects/ui/ProjectIssuesPanel.tsx @@ -116,6 +116,11 @@ const ISSUE_STATUS_ORDER: readonly ProjectIssue["status"][] = [ "Closed", ]; +export type ProjectIssuePanelItem = { + issue: ProjectIssue; + project: Project; +}; + function issueMembers( project: Project, issue: ProjectIssue, @@ -415,54 +420,69 @@ export function ProjectIssueDetail({ } export function ProjectIssuesPanel({ + error, + isLoading, + issueItems, onSelectedIssueIdChange, profiles, project, selectedIssueId, }: { + error?: unknown; + isLoading?: boolean; + issueItems?: ProjectIssuePanelItem[]; onSelectedIssueIdChange: (id: string | null) => void; profiles?: UserProfileLookup; project: Project; selectedIssueId: string | null; }) { - const issuesQuery = useProjectIssuesQuery(project); - const issues = issuesQuery.data ?? []; - const selectedIssue = - issues.find((issue) => issue.id === selectedIssueId) ?? null; + const issuesQuery = useProjectIssuesQuery( + issueItems === undefined ? project : null, + ); + const resolvedItems = + issueItems ?? (issuesQuery.data ?? []).map((issue) => ({ issue, project })); + const selectedItem = + resolvedItems.find(({ issue }) => issue.id === selectedIssueId) ?? null; + const loading = isLoading ?? issuesQuery.isLoading; + const loadError = error ?? issuesQuery.error; - if (issuesQuery.isLoading) { + if (loading) { return ; } - if (issues.length === 0) { + if (resolvedItems.length === 0) { return ( ); } - if (selectedIssue) { + if (selectedItem) { return ( ); } const groups = ISSUE_STATUS_ORDER.map((status) => ({ - items: issues.filter((issue) => issue.status === status), + items: resolvedItems.filter(({ issue }) => issue.status === status), status, })).filter((group) => group.items.length > 0); - const rangeItems = issues.map((issue) => issueSelectionItem(project, issue)); + const rangeItems = resolvedItems.map(({ issue, project: itemProject }) => + issueSelectionItem(itemProject, issue), + ); return (
@@ -477,17 +497,19 @@ export function ProjectIssuesPanel({ state={visual.progress} /> } - items={items.map((issue) => issueSelectionItem(project, issue))} + items={items.map(({ issue, project: itemProject }) => + issueSelectionItem(itemProject, issue), + )} key={status} label={status} > - {items.map((issue) => ( + {items.map(({ issue, project: itemProject }) => ( onSelectedIssueIdChange(issue.id)} profiles={profiles} - project={project} + project={itemProject} rangeItems={rangeItems} /> ))} diff --git a/desktop/src/features/projects/ui/ProjectSelectableGroup.tsx b/desktop/src/features/projects/ui/ProjectSelectableGroup.tsx index 6b5de4af824..15690d24cb1 100644 --- a/desktop/src/features/projects/ui/ProjectSelectableGroup.tsx +++ b/desktop/src/features/projects/ui/ProjectSelectableGroup.tsx @@ -15,6 +15,7 @@ export function ProjectSelectableGroup({ icon, items, label, + labelClassName, labelTestId, testId, }: { @@ -27,6 +28,7 @@ export function ProjectSelectableGroup({ icon: React.ReactNode; items: ProjectSelectionItem[]; label: string; + labelClassName?: string; labelTestId?: string; testId: string; }) { @@ -93,7 +95,10 @@ export function ProjectSelectableGroup({ type="button" > {label} diff --git a/desktop/src/features/projects/ui/ProjectWorkspaceTabList.tsx b/desktop/src/features/projects/ui/ProjectWorkspaceTabList.tsx index 065a97e5e35..58b6dd93364 100644 --- a/desktop/src/features/projects/ui/ProjectWorkspaceTabList.tsx +++ b/desktop/src/features/projects/ui/ProjectWorkspaceTabList.tsx @@ -34,6 +34,9 @@ export function ProjectTabsList({ + + Overview + Files diff --git a/desktop/src/features/projects/ui/ProjectsCategoryCreateDialogs.tsx b/desktop/src/features/projects/ui/ProjectsCategoryCreateDialogs.tsx new file mode 100644 index 00000000000..e0746ae14df --- /dev/null +++ b/desktop/src/features/projects/ui/ProjectsCategoryCreateDialogs.tsx @@ -0,0 +1,106 @@ +import * as React from "react"; +import { toast } from "sonner"; + +import { useAppNavigation } from "@/app/navigation/useAppNavigation"; +import { useChannelsQuery } from "@/features/channels/hooks"; +import type { Project } from "@/features/projects/hooks"; +import { useAddProjectChannelMutation } from "@/features/projects/useAddProjectChannel"; +import { useAddProjectRepositoryMutation } from "@/features/projects/useAddProjectRepository"; +import { AddProjectRepositoryDialog } from "@/features/projects/ui/AddProjectRepositoryDialog"; +import { CreateChannelDialog } from "@/features/sidebar/ui/CreateChannelDialog"; + +export function ProjectsCategoryCreateDialogs({ + channelOpen, + editableProjects, + onChannelOpenChange, + onRepositoryOpenChange, + ownerControlAgentPubkeyFor, + repositoryOpen, +}: { + channelOpen: boolean; + editableProjects: Project[]; + onChannelOpenChange: (open: boolean) => void; + onRepositoryOpenChange: (open: boolean) => void; + ownerControlAgentPubkeyFor: (project: Project) => string | undefined; + repositoryOpen: boolean; +}) { + const { goChannel, goProject } = useAppNavigation(); + const [channelProjectId, setChannelProjectId] = React.useState(""); + const channelProject = + editableProjects.find((project) => project.id === channelProjectId) ?? + editableProjects[0]; + const createChannelMutation = useAddProjectChannelMutation(); + const createRepositoryMutation = useAddProjectRepositoryMutation(); + const channelsQuery = useChannelsQuery({ enabled: repositoryOpen }); + const repositoryAccessChannels = React.useMemo( + () => + (channelsQuery.data ?? []).filter( + (channel) => + channel.isMember && + !channel.archivedAt && + channel.channelType !== "dm", + ), + [channelsQuery.data], + ); + + return ( + <> + { + if (!channelProject) throw new Error("Choose a project."); + const result = await createChannelMutation.mutateAsync({ + ...input, + ownerControlAgentPubkey: ownerControlAgentPubkeyFor(channelProject), + project: channelProject, + }); + toast.success(`Channel "#${result.channel.name}" created.`); + await goChannel(result.channel.id); + }} + onOpenChange={onChannelOpenChange} + testId="create-project-channel-dialog" + title="Create a project channel" + > + + + { + const result = await createRepositoryMutation.mutateAsync({ + ...input, + ownerControlAgentPubkey: ownerControlAgentPubkeyFor(input.project), + }); + toast.success(`Repository "${result.repository.name}" created.`); + await goProject(input.project.id, { + repositoryId: result.repository.id, + }); + }} + onOpenChange={onRepositoryOpenChange} + open={repositoryOpen} + projects={editableProjects} + /> + + ); +} diff --git a/desktop/src/features/projects/ui/ProjectsCreateMenu.tsx b/desktop/src/features/projects/ui/ProjectsCreateMenu.tsx deleted file mode 100644 index 56b77ee8a1d..00000000000 --- a/desktop/src/features/projects/ui/ProjectsCreateMenu.tsx +++ /dev/null @@ -1,127 +0,0 @@ -import { CircleDot, FolderGit2, GitPullRequest, Plus } from "lucide-react"; -import * as React from "react"; - -import { Button } from "@/shared/ui/button"; -import { - POPOVER_SHADOW_STYLE, - POPOVER_SURFACE_CLASS, -} from "@/shared/ui/popoverSurface"; - -const MENU_ITEM_CLASS = - "flex min-h-9 w-full items-center gap-2 rounded-lg py-2 pl-2 pr-4 text-left text-sm outline-hidden transition-colors hover:bg-muted/50 focus:bg-muted/50 focus:text-foreground focus-visible:ring-1 focus-visible:ring-ring [&_svg]:size-4 [&_svg]:shrink-0"; - -export function ProjectsCreateMenu({ - compact = false, - onCreateIssue, - onCreateProject, - onCreatePullRequest, -}: { - compact?: boolean; - onCreateIssue: () => void; - onCreateProject: () => void; - onCreatePullRequest: () => void; -}) { - const [open, setOpen] = React.useState(false); - const containerRef = React.useRef(null); - - React.useEffect(() => { - if (!open) return; - function handlePointerDown(event: PointerEvent) { - if (!containerRef.current?.contains(event.target as Node)) { - setOpen(false); - } - } - globalThis.document.addEventListener( - "pointerdown", - handlePointerDown, - true, - ); - return () => - globalThis.document.removeEventListener( - "pointerdown", - handlePointerDown, - true, - ); - }, [open]); - - function select(action: () => void) { - setOpen(false); - action(); - } - - return ( - - ); -} diff --git a/desktop/src/features/projects/ui/ProjectsOverviewChromeActions.tsx b/desktop/src/features/projects/ui/ProjectsOverviewChromeActions.tsx index ba7cce9a818..38904db7373 100644 --- a/desktop/src/features/projects/ui/ProjectsOverviewChromeActions.tsx +++ b/desktop/src/features/projects/ui/ProjectsOverviewChromeActions.tsx @@ -1,7 +1,8 @@ -import { Info, MessageCircle } from "lucide-react"; +import { MessageCircle } from "lucide-react"; import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; +import { DrawerPanelIcon } from "@/shared/ui/DrawerPanelIcon"; export function ProjectsOverviewChromeActions({ chatOpen, @@ -49,12 +50,10 @@ export function ProjectsOverviewChromeActions({ type="button" variant="ghost" > - diff --git a/desktop/src/features/projects/ui/ProjectsOverviewContextSheet.tsx b/desktop/src/features/projects/ui/ProjectsOverviewContextSheet.tsx index c964bad15cc..116292e5585 100644 --- a/desktop/src/features/projects/ui/ProjectsOverviewContextSheet.tsx +++ b/desktop/src/features/projects/ui/ProjectsOverviewContextSheet.tsx @@ -1,8 +1,7 @@ -import { Info } from "lucide-react"; import * as React from "react"; -import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; +import { DrawerPanelIcon } from "@/shared/ui/DrawerPanelIcon"; import { Sheet, SheetContent, SheetTitle } from "@/shared/ui/sheet"; export const ProjectsOverviewNarrowContextToggle = React.forwardRef< @@ -21,12 +20,10 @@ export const ProjectsOverviewNarrowContextToggle = React.forwardRef< type="button" variant="ghost" > - )); diff --git a/desktop/src/features/projects/ui/ProjectsOverviewItems.tsx b/desktop/src/features/projects/ui/ProjectsOverviewItems.tsx index 3c23c7452b0..c0e410ff22a 100644 --- a/desktop/src/features/projects/ui/ProjectsOverviewItems.tsx +++ b/desktop/src/features/projects/ui/ProjectsOverviewItems.tsx @@ -1,6 +1,7 @@ import * as React from "react"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; +import { FolderGit2, Folders } from "lucide-react"; import type { Project, ProjectActivitySummary, @@ -17,14 +18,16 @@ import { } from "@/features/projects/lib/projectShareLinks"; import { isProjectOwnedByCurrentUser, + isProjectMine, projectPeople, - type ProjectsFilter, type ProjectsViewMode, } from "@/features/projects/lib/projectsViewHelpers"; import { + type ProjectSelectionItem, selectionItemFromProject, selectionItemFromRepository, } from "@/features/projects/lib/projectSelection"; +import { ProjectSelectableGroup } from "@/features/projects/ui/ProjectSelectableGroup"; import { EmptyFilteredState, ProjectGridCard, @@ -35,7 +38,78 @@ import { RepositoryListRow, } from "@/features/projects/ui/RepositoryCards"; import { useIncrementalMount } from "@/shared/hooks/useIncrementalMount"; -import { cn } from "@/shared/lib/cn"; +import { normalizePubkey } from "@/shared/lib/pubkey"; + +const RESPONSIVE_CARD_GRID_CLASS = + "grid gap-3 [grid-template-columns:repeat(auto-fit,minmax(min(100%,16rem),1fr))]"; + +function CollectionGroup({ + children, + icon, + items, + title, +}: { + children: React.ReactNode; + icon: React.ReactNode; + items: ProjectSelectionItem[]; + title: string; +}) { + return ( + + {children} + + ); +} + +function repositoryIsMine( + repository: Repository, + currentPubkey: string | undefined, +) { + if (!currentPubkey) return false; + const viewer = normalizePubkey(currentPubkey); + return ( + normalizePubkey(repository.owner) === viewer || + repository.contributors.some((pubkey) => normalizePubkey(pubkey) === viewer) + ); +} + +function projectSelectionItems(projects: readonly Project[]) { + return projects.map((project) => + selectionItemFromProject({ + channelId: project.projectChannelId, + id: project.id, + owner: project.owner, + shareLink: projectShareLink(project), + title: project.name, + }), + ); +} + +function repositorySelectionItems( + rows: ReadonlyArray<{ project: Project; repository: Repository }>, +) { + return rows.map((row) => + selectionItemFromRepository({ + channelId: row.repository.channelId ?? row.project.projectChannelId, + id: row.repository.id, + owner: row.repository.owner, + shareLink: repositoryShareLink(row.repository), + title: row.repository.name, + }), + ); +} // Stable fallback so a cache miss cannot hand a memoized card a fresh array. const EMPTY_PEOPLE: string[] = []; @@ -43,7 +117,6 @@ const EMPTY_PEOPLE: string[] = []; export function ProjectsOverviewProjectItems({ currentPubkey, deleteDisabled, - filter, localRepoNames, onDelete, onOpen, @@ -56,7 +129,6 @@ export function ProjectsOverviewProjectItems({ }: { currentPubkey: string | undefined; deleteDisabled: boolean; - filter: ProjectsFilter; localRepoNames: Set; onDelete: (project: Project) => void; onOpen: (project: Project) => void; @@ -114,82 +186,122 @@ export function ProjectsOverviewProjectItems({ () => visibleProjects.slice(0, mountedCount), [mountedCount, visibleProjects], ); + const mountedProjectIds = React.useMemo( + () => new Set(mountedProjects.map((project) => project.id)), + [mountedProjects], + ); if (visibleProjects.length === 0) { return ; } + const groups = [ + { + items: visibleProjects.filter((project) => + isProjectMine(project, currentPubkey), + ), + title: "Mine", + }, + { + items: visibleProjects.filter( + (project) => !isProjectMine(project, currentPubkey), + ), + title: "Other projects", + }, + ].filter((group) => group.items.length > 0); if (viewMode === "grid") { return ( -
- {mountedProjects.map((project) => { - const summary = summaries?.[project.id]; - return ( -
- +
+ {groups.map((group) => ( + } + items={projectSelectionItems(group.items)} + key={group.title} + title={group.title} + > +
+ {group.items + .filter((project) => mountedProjectIds.has(project.id)) + .map((project) => { + const summary = summaries?.[project.id]; + return ( +
+ +
+ ); + })}
- ); - })} +
+ ))}
); } return ( -
- {visibleProjects.map((project) => { - const summary = summaries?.[project.id]; - return ( -
- +
+ {groups.map((group) => ( + } + items={projectSelectionItems(group.items)} + key={group.title} + title={group.title} + > +
+ {group.items.map((project) => { + const summary = summaries?.[project.id]; + return ( +
+ +
+ ); + })}
- ); - })} +
+ ))}
); } export function ProjectsOverviewRepositoryItems({ + currentPubkey, localRepoNames, onOpen, onOpenTerminal, @@ -198,6 +310,7 @@ export function ProjectsOverviewRepositoryItems({ viewMode, visibleRepositories, }: { + currentPubkey: string | undefined; localRepoNames: Set; onOpen: (project: Project, repository: Repository) => void; onOpenTerminal: (repository: Repository) => void; @@ -233,53 +346,102 @@ export function ProjectsOverviewRepositoryItems({ () => visibleRepositories.slice(0, mountedCount), [mountedCount, visibleRepositories], ); + const mountedRepositoryAddresses = React.useMemo( + () => + new Set( + mountedRepositories.map(({ repository }) => repository.repoAddress), + ), + [mountedRepositories], + ); if (visibleRepositories.length === 0) { return ; } + const groups = [ + { + items: visibleRepositories.filter(({ repository }) => + repositoryIsMine(repository, currentPubkey), + ), + title: "Mine", + }, + { + items: visibleRepositories.filter( + ({ repository }) => !repositoryIsMine(repository, currentPubkey), + ), + title: "Other repositories", + }, + ].filter((group) => group.items.length > 0); if (viewMode === "grid") { return ( -
- {mountedRepositories.map(({ project, repository }) => ( -
+ {groups.map((group) => ( + } + items={repositorySelectionItems(group.items)} + key={group.title} + title={group.title} > - -
+
+ {group.items + .filter(({ repository }) => + mountedRepositoryAddresses.has(repository.repoAddress), + ) + .map(({ project, repository }) => ( +
+ +
+ ))} +
+ ))}
); } return ( -
- {visibleRepositories.map(({ project, repository }) => ( -
+ {groups.map((group) => ( + } + items={repositorySelectionItems(group.items)} + key={group.title} + title={group.title} > - -
+
+ {group.items.map(({ project, repository }) => ( +
+ +
+ ))} +
+ ))}
); diff --git a/desktop/src/features/projects/ui/ProjectsOverviewPanel.tsx b/desktop/src/features/projects/ui/ProjectsOverviewPanel.tsx index 0e873a11d2b..6f34d053957 100644 --- a/desktop/src/features/projects/ui/ProjectsOverviewPanel.tsx +++ b/desktop/src/features/projects/ui/ProjectsOverviewPanel.tsx @@ -26,10 +26,10 @@ import type { ProjectsActivityDigest } from "@/features/projects/lib/projectsAct import { useProjectSelection } from "@/features/projects/lib/useProjectSelection"; import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; -import { ProjectsCreateMenu } from "./ProjectsCreateMenu"; import { ProjectsOverviewPeople } from "./ProjectsOverviewRail"; import { ProjectsSelectionCountMenu } from "./ProjectsSelectionCountMenu"; import { + type OverviewContextAction, type OverviewContextStatIcon, type ProjectsOverviewSection, projectsOverviewContext, @@ -56,7 +56,10 @@ type ProjectsOverviewPanelProps = { type ProjectsOverviewContextPanelProps = { filter: ProjectsFilter; + canCreateTarget: boolean; issues: ProjectIssue[]; + onAddChannel: () => void; + onAddRepository: () => void; onChatWithAgent: (items: ProjectSelectionItem[]) => void; onCreateIssue: () => void; onCreateProject: () => void; @@ -70,25 +73,48 @@ type ProjectsOverviewContextPanelProps = { summaries?: Record; }; -function OverviewActionButton({ - children, - onClick, - testId, +function OverviewCreateButton({ + action, + canCreateTarget, + onAddChannel, + onAddRepository, + onCreateIssue, + onCreateProject, + onCreatePullRequest, }: { - children: React.ReactNode; - onClick: () => void; - testId?: string; + action: Exclude; + canCreateTarget: boolean; + onAddChannel: () => void; + onAddRepository: () => void; + onCreateIssue: () => void; + onCreateProject: () => void; + onCreatePullRequest: () => void; }) { + const actionHandler = + action.kind === "issue" + ? onCreateIssue + : action.kind === "pullRequest" + ? onCreatePullRequest + : action.kind === "project" + ? onCreateProject + : action.kind === "channel" + ? onAddChannel + : onAddRepository; + const requiresProject = + action.kind === "channel" || action.kind === "repository"; return ( ); } @@ -172,8 +198,11 @@ export function ProjectsActivityIntro({ } export function ProjectsOverviewContextPanel({ + canCreateTarget, filter, issues, + onAddChannel, + onAddRepository, onChatWithAgent, onCreateIssue, onCreateProject, @@ -215,13 +244,6 @@ export function ProjectsOverviewContextPanel({ summaries, ], ); - const actionHandler = - context.action?.kind === "issue" - ? onCreateIssue - : context.action?.kind === "pullRequest" - ? onCreatePullRequest - : onCreateProject; - return (
{context.title} - + {context.action ? ( + + ) : null}
)} {selectionPresentation ? null : (
- {context.action ? ( - - - {context.action.label} - - ) : null}
{ @@ -68,25 +91,25 @@ export function ProjectsSelectionCountMenu({ return (
- - - - - - Selection - -

- {presentation.title} -

-
+ +

+ {presentation.title} +

-
+
{presentation.actions .filter( (action) => diff --git a/desktop/src/features/projects/ui/ProjectsView.tsx b/desktop/src/features/projects/ui/ProjectsView.tsx index 9dec955af0d..f33659bcfc5 100644 --- a/desktop/src/features/projects/ui/ProjectsView.tsx +++ b/desktop/src/features/projects/ui/ProjectsView.tsx @@ -2,7 +2,9 @@ import * as React from "react"; import { toast } from "sonner"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; +import { useManagedAgentsQuery } from "@/features/agents/hooks"; import { useUsersBatchQuery } from "@/features/profile/hooks"; +import { ownsAuthorAgent } from "@/features/profile/lib/identity"; import { type Project, type ProjectIssue, @@ -17,6 +19,7 @@ import { import { useRepositoryActivitySummariesQuery } from "@/features/projects/repositoryActivityHooks"; import { useCreateProjectMutation } from "@/features/projects/useCreateProject"; import { isExplicitProject } from "@/features/projects/projectModels"; +import { projectsWithWorkItemRepositories } from "@/features/projects/projectWorkItems"; import { useProjectsRepoSnapshotsQuery } from "@/features/projects/useProjectsRepoSnapshots"; import { buildProjectSelectionAgentContext } from "@/features/projects/lib/projectDetailAgentContext"; import { buildProjectsActivityDigest } from "@/features/projects/lib/projectsActivityDigest"; @@ -53,6 +56,7 @@ import { CreateProjectDialog } from "@/features/projects/ui/CreateProjectDialog" import { CreateProjectIssueDialog } from "@/features/projects/ui/CreateProjectIssueDialog"; import { CreatePullRequestDialog } from "@/features/projects/ui/CreatePullRequestDialog"; import { ProjectAgentChatPanel } from "@/features/projects/ui/ProjectAgentChatPanel"; +import { ProjectsCategoryCreateDialogs } from "@/features/projects/ui/ProjectsCategoryCreateDialogs"; import { ProjectsIssuesList } from "@/features/projects/ui/ProjectsIssuesList"; import { ProjectsWorkspaceChrome } from "@/features/projects/ui/ProjectDetailChrome"; import { ProjectsPullRequestsList } from "@/features/projects/ui/ProjectsPullRequestsList"; @@ -115,6 +119,7 @@ export function ProjectsView() { useProjectsScrollIndicator(); const projectsQuery = useProjectsQuery(); const identityQuery = useIdentityQuery(); + const managedAgentsQuery = useManagedAgentsQuery(); const projectReadModels = projectsQuery.data ?? []; const projects = React.useMemo( () => projectReadModels.filter(isExplicitProject), @@ -146,7 +151,11 @@ export function ProjectsView() { const repositoryActivitySummariesQuery = useRepositoryActivitySummariesQuery( filter === "repositories" ? projectReadModels : [], ); - const projectsWorkItemsQuery = useProjectsWorkItemsQuery(projectReadModels); + const workItemProjects = React.useMemo( + () => projectsWithWorkItemRepositories(projectReadModels), + [projectReadModels], + ); + const projectsWorkItemsQuery = useProjectsWorkItemsQuery(workItemProjects); // One blobless clone per primary Buzz repository, only while the overview // header is visible. const snapshotProjects = React.useMemo( @@ -169,6 +178,8 @@ export function ProjectsView() { memberChannelIds, ); const [createProjectOpen, setCreateProjectOpen] = React.useState(false); + const [createChannelOpen, setCreateChannelOpen] = React.useState(false); + const [createRepositoryOpen, setCreateRepositoryOpen] = React.useState(false); const [createIssueOpen, setCreateIssueOpen] = React.useState(false); const [createPullRequestOpen, setCreatePullRequestOpen] = React.useState(false); @@ -233,6 +244,42 @@ export function ProjectsView() { ); const deleteProjectMutation = useDeleteProjectMutation(); const currentPubkey = identityQuery.data?.pubkey; + const managedAgentPubkeys = React.useMemo( + () => + new Set( + (managedAgentsQuery.data ?? []).map((agent) => + normalizePubkey(agent.pubkey), + ), + ), + [managedAgentsQuery.data], + ); + const editableProjects = React.useMemo(() => { + if (!currentPubkey) return []; + const viewer = normalizePubkey(currentPubkey); + return projects.filter((project) => { + const owner = normalizePubkey(project.owner); + return ( + owner === viewer || + managedAgentPubkeys.has(owner) || + ownsAuthorAgent(profiles?.[owner], currentPubkey) + ); + }); + }, [currentPubkey, managedAgentPubkeys, profiles, projects]); + const ownerControlAgentPubkeyFor = React.useCallback( + (project: Project) => { + const owner = normalizePubkey(project.owner); + if ( + owner === normalizePubkey(currentPubkey ?? "") || + managedAgentPubkeys.has(owner) + ) { + return undefined; + } + return ownsAuthorAgent(profiles?.[owner], currentPubkey) + ? project.owner + : undefined; + }, + [currentPubkey, managedAgentPubkeys, profiles], + ); const handleViewModeChange = React.useCallback( (nextViewMode: ProjectsViewMode) => { @@ -526,7 +573,6 @@ export function ProjectsView() { 0, filter, issues: contextIssues, + onAddChannel: () => setCreateChannelOpen(true), + onAddRepository: () => setCreateRepositoryOpen(true), onChatWithAgent: (items: ProjectSelectionItem[]) => setSelectionAgentContext(buildProjectSelectionAgentContext(items)), onCreateIssue: () => setCreateIssueOpen(true), @@ -735,6 +785,14 @@ export function ProjectsView() { open={createIssueOpen} projects={projects} /> +
{ +test("activity pod shows workspace details without a create action", () => { const context = projectsOverviewContext({ filter: "all", issues: [], @@ -74,7 +74,7 @@ test("activity pod shows workspace details and a create-project action", () => { assert.equal(context.title, "Projects"); assert.equal(context.detailsTitle, "Details"); - assert.equal(context.action?.label, "Create project"); + assert.equal(context.action, null); assert.deepEqual( context.stats.map((stat) => [stat.label, stat.count]), [ @@ -103,7 +103,7 @@ test("projects pod keeps create-project and drops task/review totals", () => { ); }); -test("repositories pod matches repository activity copy", () => { +test("repositories pod matches repository activity copy and add action", () => { const context = projectsOverviewContext({ filter: "repositories", issues: [makeIssue("In Progress"), makeIssue("Done")], @@ -113,7 +113,8 @@ test("repositories pod matches repository activity copy", () => { assert.equal(context.title, "Repositories"); assert.equal(context.detailsTitle, "Repository activity"); - assert.equal(context.action, null); + assert.equal(context.action?.kind, "repository"); + assert.equal(context.action?.label, "Add repository"); assert.deepEqual( context.stats.map((stat) => [stat.label, stat.count]), [ @@ -124,7 +125,7 @@ test("repositories pod matches repository activity copy", () => { ); }); -test("channels pod titles itself and omits a primary create action", () => { +test("channels pod titles itself and provides an add action", () => { const context = projectsOverviewContext({ filter: "channels", issues: [], @@ -134,7 +135,8 @@ test("channels pod titles itself and omits a primary create action", () => { assert.equal(context.title, "Channels"); assert.equal(context.detailsTitle, "Details"); - assert.equal(context.action, null); + assert.equal(context.action?.kind, "channel"); + assert.equal(context.action?.label, "Add channel"); assert.deepEqual( context.stats.map((stat) => stat.label), ["Channels", "Projects", "Repositories"], diff --git a/desktop/src/features/projects/ui/projectsOverviewContext.ts b/desktop/src/features/projects/ui/projectsOverviewContext.ts index ec4dde0f000..0b4c5bfc693 100644 --- a/desktop/src/features/projects/ui/projectsOverviewContext.ts +++ b/desktop/src/features/projects/ui/projectsOverviewContext.ts @@ -32,7 +32,7 @@ export type OverviewContextStatIcon = | "merged"; export type OverviewContextAction = { - kind: "project" | "issue" | "pullRequest"; + kind: "channel" | "issue" | "project" | "pullRequest" | "repository"; label: string; testId: string; } | null; @@ -238,7 +238,11 @@ export function projectsOverviewContext( if (filter === "repositories") { return { - action: null, + action: { + kind: "repository", + label: "Add repository", + testId: "projects-overview-add-repository", + }, detailsTitle: "Repository activity", people, stats: [ @@ -267,7 +271,11 @@ export function projectsOverviewContext( if (filter === "channels") { return { - action: null, + action: { + kind: "channel", + label: "Add channel", + testId: "projects-overview-add-channel", + }, detailsTitle: "Details", people, stats: [ @@ -362,7 +370,7 @@ export function projectsOverviewContext( if (filter === "all") { return { - action: createProjectAction(), + action: null, detailsTitle: "Details", people, stats: [ diff --git a/desktop/src/features/projects/ui/useCreateProjectFormSettings.test.mjs b/desktop/src/features/projects/ui/useCreateProjectFormSettings.test.mjs new file mode 100644 index 00000000000..85294b63938 --- /dev/null +++ b/desktop/src/features/projects/ui/useCreateProjectFormSettings.test.mjs @@ -0,0 +1,44 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { buildCreateProjectAgents } from "./useCreateProjectFormSettings.ts"; + +const runtime = { + id: "buzz-agent", + label: "Buzz Agent", + availability: "available", + command: "buzz-agent", + binaryPath: "/bin/buzz-agent", +}; + +function persona(id, displayName) { + return { + id, + displayName, + avatarUrl: null, + systemPrompt: `${displayName} instructions`, + runtime: null, + model: null, + }; +} + +test("project creation expands a team and deduplicates the separately selected persona", () => { + const alpha = persona("alpha", "Alpha"); + const beta = persona("beta", "Beta"); + const agents = buildCreateProjectAgents({ + agentPersonaId: "beta", + personas: [alpha, beta], + runtimes: [runtime], + teamId: "builders", + teams: [{ id: "builders", personaIds: ["alpha", "beta"] }], + }); + + assert.deepEqual( + agents.map(({ personaId, teamId }) => ({ personaId, teamId })), + [ + { personaId: "alpha", teamId: "builders" }, + { personaId: "beta", teamId: "builders" }, + ], + ); + assert.equal(agents[0].runtime, runtime); +}); diff --git a/desktop/src/features/projects/ui/useCreateProjectFormSettings.ts b/desktop/src/features/projects/ui/useCreateProjectFormSettings.ts index 00a70c587a5..b1099496167 100644 --- a/desktop/src/features/projects/ui/useCreateProjectFormSettings.ts +++ b/desktop/src/features/projects/ui/useCreateProjectFormSettings.ts @@ -4,31 +4,126 @@ import type { CreateChannelManagedAgentInput } from "@/features/agents/channelAg import { useAvailableAcpRuntimes, usePersonasQuery, + useTeamsQuery, } from "@/features/agents/hooks"; import { getActivePersonas } from "@/features/agents/lib/catalog"; import { resolvePersonaRuntime } from "@/features/agents/lib/resolvePersonaRuntime"; +import { + getUsableTeams, + resolveTeamPersonas, +} from "@/features/agents/lib/teamPersonas"; +import { useChannelTemplatesQuery } from "@/features/channel-templates/hooks"; +import { + PROJECT_HOME_CHANNEL_TEMPLATE, + PROJECT_HOME_TEMPLATE_ID, +} from "@/features/projects/lib/projectHomeTemplate"; import type { ProjectListingVisibility } from "@/features/projects/projectCreation"; -import type { ChannelVisibility } from "@/shared/api/types"; +import type { + AcpRuntime, + AgentPersona, + AgentTeam, + ChannelTemplate, + ChannelVisibility, +} from "@/shared/api/types"; -export function useCreateProjectFormSettings(active: boolean) { +/** Expand the selected team and persona into deduplicated channel agents. */ +export function buildCreateProjectAgents(input: { + agentPersonaId: string; + personas: AgentPersona[]; + runtimes: AcpRuntime[]; + teamId: string; + teams: AgentTeam[]; +}): CreateChannelManagedAgentInput[] { + const defaultRuntime = input.runtimes[0] ?? null; + const agents: CreateChannelManagedAgentInput[] = []; + const seenPersonaIds = new Set(); + const addPersona = (persona: AgentPersona, selectedTeamId?: string) => { + if (seenPersonaIds.has(persona.id)) return; + const resolved = resolvePersonaRuntime( + persona.runtime, + input.runtimes, + defaultRuntime, + false, + ); + if (!resolved.runtime) { + throw new Error( + resolved.warnings[0] ?? + "No agent runtimes are available. Install a runtime to add agents.", + ); + } + seenPersonaIds.add(persona.id); + agents.push({ + runtime: resolved.runtime, + name: persona.displayName, + personaId: persona.id, + teamId: selectedTeamId, + harnessOverride: false, + systemPrompt: persona.systemPrompt, + avatarUrl: persona.avatarUrl ?? undefined, + model: persona.model ?? undefined, + role: "bot", + backend: { type: "local" }, + }); + }; + if (input.teamId) { + const team = input.teams.find((entry) => entry.id === input.teamId); + if (!team) throw new Error("Choose a team that still exists."); + const resolution = resolveTeamPersonas(team, input.personas); + for (const persona of resolution.resolvedPersonas) { + addPersona(persona, team.id); + } + } + if (input.agentPersonaId) { + const persona = input.personas.find( + (entry) => entry.id === input.agentPersonaId, + ); + if (!persona) throw new Error("Choose an agent that still exists."); + addPersona(persona); + } + return agents; +} + +export function useCreateProjectFormSettings( + active: boolean, + onTemplateDescriptionChange?: (description: string) => void, +) { const personasQuery = usePersonasQuery({ enabled: active }); const runtimesQuery = useAvailableAcpRuntimes({ enabled: active }); + const teamsQuery = useTeamsQuery(); + const templatesQuery = useChannelTemplatesQuery(); const [channelVisibility, setChannelVisibility] = React.useState("open"); const [projectVisibility, setProjectVisibility] = React.useState("listed"); const [agentPersonaId, setAgentPersonaId] = React.useState(""); + const [teamId, setTeamId] = React.useState(""); + const [templateId, setTemplateId] = React.useState(PROJECT_HOME_TEMPLATE_ID); const personas = React.useMemo( () => getActivePersonas(personasQuery.data ?? []), [personasQuery.data], ); + const teams = React.useMemo( + () => getUsableTeams(teamsQuery.data ?? [], personas), + [personas, teamsQuery.data], + ); + const templates = React.useMemo( + () => [ + PROJECT_HOME_CHANNEL_TEMPLATE, + ...(templatesQuery.data ?? []).filter( + (template) => template.id !== PROJECT_HOME_TEMPLATE_ID, + ), + ], + [templatesQuery.data], + ); React.useEffect(() => { if (!active) return; setChannelVisibility("open"); setProjectVisibility("listed"); setAgentPersonaId(""); + setTeamId(""); + setTemplateId(PROJECT_HOME_TEMPLATE_ID); }, [active]); React.useEffect(() => { @@ -39,52 +134,73 @@ export function useCreateProjectFormSettings(active: boolean) { setAgentPersonaId(""); } }, [agentPersonaId, personas]); + React.useEffect(() => { + if (teamId && !teams.some((team) => team.id === teamId)) { + setTeamId(""); + } + }, [teamId, teams]); + React.useEffect(() => { + if ( + templateId && + !templates.some((template) => template.id === templateId) + ) { + setTemplateId(""); + } + }, [templateId, templates]); - const buildAgents = - React.useCallback((): CreateChannelManagedAgentInput[] => { - if (!agentPersonaId) return []; - const persona = personas.find((entry) => entry.id === agentPersonaId); - if (!persona) { - throw new Error("Choose an agent that still exists."); + const buildAgents = React.useCallback( + () => + buildCreateProjectAgents({ + agentPersonaId, + personas, + runtimes: runtimesQuery.data, + teamId, + teams, + }), + [agentPersonaId, personas, runtimesQuery.data, teamId, teams], + ); + + const applyTemplate = React.useCallback( + (template: ChannelTemplate) => { + setTemplateId(template.id); + setChannelVisibility(template.visibility); + if (template.id !== PROJECT_HOME_TEMPLATE_ID) { + onTemplateDescriptionChange?.(template.description ?? ""); } - const defaultRuntime = runtimesQuery.data[0] ?? null; - const resolved = resolvePersonaRuntime( - persona.runtime, - runtimesQuery.data, - defaultRuntime, - false, - ); - if (!resolved.runtime) { - throw new Error( - resolved.warnings[0] ?? - "No agent runtimes are available. Install a runtime to add an agent.", - ); + }, + [onTemplateDescriptionChange], + ); + const handleTemplateChange = React.useCallback( + (nextTemplateId: string) => { + if (!nextTemplateId) { + setTemplateId(""); + setChannelVisibility("open"); + onTemplateDescriptionChange?.(""); + return; } - return [ - { - runtime: resolved.runtime, - name: persona.displayName, - personaId: persona.id, - harnessOverride: false, - systemPrompt: persona.systemPrompt, - avatarUrl: persona.avatarUrl ?? undefined, - model: persona.model ?? undefined, - role: "bot", - backend: { type: "local" }, - }, - ]; - }, [agentPersonaId, personas, runtimesQuery.data]); + const template = templates.find((entry) => entry.id === nextTemplateId); + if (template) applyTemplate(template); + }, + [applyTemplate, onTemplateDescriptionChange, templates], + ); return { agentPersonaId, buildAgents, channelVisibility, + handleTemplateCreated: applyTemplate, + handleTemplateChange, personas, projectVisibility, runtimesAvailable: runtimesQuery.data.length > 0, setAgentPersonaId, setChannelVisibility, setProjectVisibility, + setTeamId, + teamId, + teams, + templateId, + templates, }; } diff --git a/desktop/src/features/projects/useCreateProject.ts b/desktop/src/features/projects/useCreateProject.ts index 8526e0317d6..eb1ea080706 100644 --- a/desktop/src/features/projects/useCreateProject.ts +++ b/desktop/src/features/projects/useCreateProject.ts @@ -1,10 +1,12 @@ import * as React from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; import { channelsQueryKey, upsertCachedChannel, } from "@/features/channels/hooks"; +import { useApplyTemplate } from "@/features/channel-templates/useApplyTemplate"; import { type Project, projectsQueryKey } from "@/features/projects/hooks"; import { createProject, @@ -13,6 +15,10 @@ import { type CreateProjectResumeState, } from "@/features/projects/createProject"; import { addProjectToSidebar } from "@/features/projects/lib/projectSidebarMembership"; +import { + applyProjectHomeCanvas, + PROJECT_HOME_TEMPLATE_ID, +} from "@/features/projects/lib/projectHomeTemplate"; import type { Channel } from "@/shared/api/types"; import { getCachedRelayOrigin } from "@/shared/lib/mediaUrl"; @@ -21,6 +27,7 @@ export type { CreateProjectInput, CreateProjectResult }; /** Mutation that creates a project home and inserts it into the caches. */ export function useCreateProjectMutation() { const queryClient = useQueryClient(); + const { applyAgents, applyCanvas } = useApplyTemplate(); const resumeRef = React.useRef({ channels: new Map(), projectIds: new Set(), @@ -29,7 +36,7 @@ export function useCreateProjectMutation() { return useMutation({ mutationFn: (input: CreateProjectInput) => createProject(input, resumeRef.current), - onSuccess: ({ channel, project }) => { + onSuccess: async ({ channel, project }, input) => { addProjectToSidebar( project.projectAddress, getCachedRelayOrigin(), @@ -57,6 +64,25 @@ export function useCreateProjectMutation() { queryKey: channelsQueryKey, refetchType: "none", }); + const useProjectHomeTemplate = + input.templateId === undefined || + input.templateId === PROJECT_HOME_TEMPLATE_ID; + if (useProjectHomeTemplate) { + const applied = await applyProjectHomeCanvas({ + channelId: channel.id, + project, + }); + if (!applied) { + toast.warning( + "Project created, but its project-home canvas could not be added.", + ); + } + } else if (input.templateId) { + await Promise.all([ + applyCanvas(input.templateId, channel.id, channel.name), + applyAgents(input.templateId, channel.id), + ]); + } } void queryClient.invalidateQueries({ queryKey: projectsQueryKey }); }, diff --git a/desktop/src/features/projects/useProjectRepositorySnapshots.ts b/desktop/src/features/projects/useProjectRepositorySnapshots.ts new file mode 100644 index 00000000000..1470772a489 --- /dev/null +++ b/desktop/src/features/projects/useProjectRepositorySnapshots.ts @@ -0,0 +1,67 @@ +import { useQueries } from "@tanstack/react-query"; + +import type { + ProjectRepoSnapshot, + Repository, +} from "@/features/projects/hooks"; +import { fetchRepoState } from "@/features/projects/hooks"; +import { resolveProjectDefaultBranch } from "@/features/projects/lib/projectBranches"; +import { getProjectRepoSnapshot } from "@/shared/api/projectGit"; + +export type ProjectRepositorySnapshotResult = { + error: unknown; + isLoading: boolean; + repository: Repository; + snapshot: ProjectRepoSnapshot | null; +}; + +/** Loads each project repository independently so one failure stays partial. */ +export function useProjectRepositorySnapshots( + repositories: Repository[], + enabled = true, +): ProjectRepositorySnapshotResult[] { + const queries = useQueries({ + queries: repositories.map((repository) => ({ + enabled: Boolean(enabled && repository.cloneUrls[0]), + queryFn: async () => { + const cloneUrl = repository.cloneUrls[0]; + if (!cloneUrl) return null; + const repoState = await fetchRepoState(repository); + const defaultBranch = resolveProjectDefaultBranch( + repository.defaultBranch, + repoState, + ); + return getProjectRepoSnapshot({ + baseBranch: defaultBranch, + cloneUrl, + defaultBranch, + }); + }, + queryKey: [ + "project", + repository.id, + "repo-snapshot", + repository.defaultBranch, + "none", + "none", + "no-tag", + "no-tag-commit", + ], + retry: 1, + staleTime: 30_000, + })), + }); + + return repositories.map((repository, index) => { + const query = queries[index]; + return { + error: + enabled && !repository.cloneUrls[0] + ? new Error("Repository not found on the relay.") + : query?.error, + isLoading: query?.isLoading ?? false, + repository, + snapshot: query?.data ?? null, + }; + }); +} diff --git a/desktop/src/features/sidebar/ui/CreateChannelDialog.tsx b/desktop/src/features/sidebar/ui/CreateChannelDialog.tsx index 425b2baa095..7ac6fb75076 100644 --- a/desktop/src/features/sidebar/ui/CreateChannelDialog.tsx +++ b/desktop/src/features/sidebar/ui/CreateChannelDialog.tsx @@ -1,3 +1,5 @@ +import type { ReactNode } from "react"; + import type { ChannelVisibility } from "@/shared/api/types"; import { ChooserDialogContent } from "@/shared/ui/chooser-dialog-content"; import { Dialog } from "@/shared/ui/dialog"; @@ -17,6 +19,7 @@ type ChannelKind = "stream" | "forum"; type CreateChannelDialogProps = { /** Which kind of channel to create, or null when closed. */ channelKind: ChannelKind | null; + children?: ReactNode; description?: string; isCreating: boolean; onOpenChange: (open: boolean) => void; @@ -33,6 +36,7 @@ type CreateChannelDialogProps = { export function CreateChannelDialog({ channelKind, + children, description, isCreating, onOpenChange, @@ -80,6 +84,7 @@ export function CreateChannelDialog({ id={CREATE_CHANNEL_FORM_ID} onSubmit={form.handleSubmit} > + {children} diff --git a/desktop/src/features/sidebar/ui/SidebarSection.tsx b/desktop/src/features/sidebar/ui/SidebarSection.tsx index 1a6403fb24d..6c41d9d885f 100644 --- a/desktop/src/features/sidebar/ui/SidebarSection.tsx +++ b/desktop/src/features/sidebar/ui/SidebarSection.tsx @@ -1,13 +1,5 @@ import type * as React from "react"; -import { - BellOff, - ChevronDown, - CircleDot, - FileText, - Hash, - Lock, - X, -} from "lucide-react"; +import { BellOff, ChevronDown, CircleDot, X } from "lucide-react"; import { ContextMenu, @@ -18,6 +10,7 @@ import { import { ChannelContextMenuItems } from "@/features/sidebar/ui/ChannelContextMenu"; import type { ActiveChannelTurnSummary } from "@/features/agents/activeAgentTurnsStore"; import { formatElapsed } from "@/features/agents/ui/agentSessionUtils"; +import { ChannelGlyph } from "@/features/channels/ui/ChannelGlyph"; import { getEphemeralChannelDisplay } from "@/features/channels/lib/ephemeralChannel"; import { EphemeralChannelBadge } from "@/features/channels/ui/EphemeralChannelBadge"; import { @@ -239,15 +232,7 @@ function SidebarChannelIcon({ ); } - if (channel.visibility === "private") { - return ; - } - - if (channel.channelType === "forum") { - return ; - } - - return ; + return ; } export function ChannelMenuButton({ diff --git a/desktop/tests/e2e/project-commit-detail.spec.ts b/desktop/tests/e2e/project-commit-detail.spec.ts index c7552195e58..6c84a170a93 100644 --- a/desktop/tests/e2e/project-commit-detail.spec.ts +++ b/desktop/tests/e2e/project-commit-detail.spec.ts @@ -18,6 +18,11 @@ async function enableProjectsFeature(page: import("@playwright/test").Page) { }); } +async function openCreateProjectDialog(page: import("@playwright/test").Page) { + await page.getByTestId("projects-section-projects").click(); + await page.getByTestId("projects-overview-create-project").click(); +} + async function addProjectToSidebar( page: import("@playwright/test").Page, dtag: string, @@ -178,19 +183,17 @@ test("top-level project lists show metadata and overflow actions", async ({ await expect( page.getByRole("button", { name: "Filter reviews" }), ).toHaveCount(0); - await page.getByTestId("projects-create-menu").hover(); - await expect(page.getByRole("menuitem", { name: "Project" })).toBeVisible(); - await expect(page.getByRole("menuitem", { name: "Task" })).toBeVisible(); - await page.getByRole("menuitem", { name: "Review", exact: true }).click(); + await page.getByTestId("projects-overview-create-pull-request").click(); await expect(page.getByTestId("create-pull-request-dialog")).toBeVisible(); await expect( page.getByTestId("create-pull-request-repository"), ).toBeVisible(); await page.keyboard.press("Escape"); - await page.getByTestId("projects-create-menu").hover(); - await page.getByRole("menuitem", { name: "Task" }).click(); + await page.getByRole("button", { name: "Tasks", exact: true }).click(); + await page.getByTestId("projects-overview-create-issue").click(); await expect(page.getByTestId("create-issue-repository")).toBeVisible(); await page.keyboard.press("Escape"); + await page.getByRole("button", { name: "Reviews", exact: true }).click(); const pullRequestRow = page .locator('[data-testid^="projects-pr-row-"]') .first(); @@ -258,13 +261,16 @@ test("creating a project opens its channel conversation", async ({ page }) => { await installMockBridge(page); await page.goto("/", { waitUntil: "domcontentloaded" }); await page.getByTestId("open-projects-view").click(); - await page.getByTestId("projects-create-menu").hover(); - await page.getByRole("menuitem", { name: "Project" }).click(); + await openCreateProjectDialog(page); await page.getByTestId("create-project-name").fill("multi-repo-demo"); await page .getByTestId("create-project-description") .fill("A grouped project created through the desktop app."); await expect(page.getByTestId("create-project-listing")).toHaveText("Listed"); + await expect(page.getByTestId("create-project-template")).toHaveText( + "Project home", + ); + await expect(page.getByTestId("create-project-team")).toHaveText("None"); await expect(page.getByTestId("create-project-agent")).toHaveText("None"); await page.getByTestId("create-project-submit").click(); @@ -287,6 +293,11 @@ test("creating a project opens its channel conversation", async ({ page }) => { await expect( page.getByTestId("chat-header").getByTestId("project-channel-icon"), ).toBeVisible(); + await expect( + page + .getByTestId("channel-multi-repo-demo") + .getByTestId("project-channel-icon"), + ).toBeVisible(); await expect( page.getByTestId("channel-intro-action-add-files"), ).toBeVisible(); @@ -298,6 +309,11 @@ test("creating a project opens its channel conversation", async ({ page }) => { await page.keyboard.press("Escape"); await expect(page.getByTestId("add-project-repository-dialog")).toBeHidden(); await expect(page.getByTestId("project-home-summary-column")).toBeVisible(); + await expect( + page + .getByTestId("project-home-summary-column") + .getByRole("heading", { name: "Overview", exact: true }), + ).toHaveCount(0); await expect( page .getByTestId("project-home-summary-column") @@ -327,8 +343,46 @@ test("creating a project opens its channel conversation", async ({ page }) => { await expect( page.getByTestId("project-home-context-channel"), ).not.toContainText("people in this channel"); - await expect(page.getByTestId("add-project-channel")).toBeVisible(); - await expect(page.getByTestId("add-project-repository")).toBeVisible(); + const channelSection = page.getByTestId("project-home-context-channel"); + const channelSectionToggle = channelSection.getByRole("button", { + name: "Channels", + exact: true, + }); + await channelSectionToggle.click(); + await expect( + channelSection.getByTestId("project-home-context-home-channel"), + ).toHaveCount(0); + await channelSectionToggle.click(); + await expect( + channelSection.getByTestId("project-home-context-home-channel"), + ).toBeVisible(); + const codebaseSection = page.getByTestId("project-home-context-codebase"); + const codebaseSectionToggle = codebaseSection.getByRole("button", { + name: "Codebase", + exact: true, + }); + await codebaseSectionToggle.click(); + await expect( + codebaseSection.getByTestId("project-home-context-repo-multi-repo-demo"), + ).toHaveCount(0); + await codebaseSectionToggle.click(); + const channelAction = page.getByTestId("add-project-channel").locator(".."); + const repositoryAction = page + .getByTestId("add-project-repository") + .locator(".."); + await page.evaluate(() => { + if (document.activeElement instanceof HTMLElement) { + document.activeElement.blur(); + } + }); + await page.mouse.move(1, 1); + await expect(channelAction).toHaveCSS("opacity", "0"); + await expect(repositoryAction).toHaveCSS("opacity", "0"); + await page.getByTestId("project-home-context-channel").hover(); + await expect(channelAction).toHaveCSS("opacity", "1"); + await page.getByTestId("project-home-context-codebase").hover(); + await expect(repositoryAction).toHaveCSS("opacity", "1"); + await page.getByTestId("project-home-context-channel").hover(); await page.getByTestId("add-project-channel").click(); await expect(page.getByTestId("create-project-channel-dialog")).toBeVisible(); await page.keyboard.press("Escape"); @@ -419,8 +473,7 @@ test("creating a project opens its channel conversation", async ({ page }) => { .getByTestId("project-detail-chrome") .getByRole("button", { name: "Projects" }) .click(); - await page.getByTestId("projects-create-menu").hover(); - await page.getByRole("menuitem", { name: "Project" }).click(); + await openCreateProjectDialog(page); await page.getByTestId("create-project-name").fill("multi-repo-demo"); await page.getByTestId("create-project-submit").click(); await expect(page.getByTestId("create-project-dialog")).toBeVisible(); @@ -451,8 +504,7 @@ test("unsupported relays cannot create a channel-first project", async ({ await installMockBridge(page); await page.goto("/", { waitUntil: "domcontentloaded" }); await page.getByTestId("open-projects-view").click(); - await page.getByTestId("projects-create-menu").hover(); - await page.getByRole("menuitem", { name: "Project" }).click(); + await openCreateProjectDialog(page); await page.getByTestId("create-project-name").fill("legacy-fallback"); await page.getByTestId("create-project-submit").click(); @@ -484,8 +536,7 @@ test("project creation can retry after its repository publication fails", async await installMockBridge(page); await page.goto("/", { waitUntil: "domcontentloaded" }); await page.getByTestId("open-projects-view").click(); - await page.getByTestId("projects-create-menu").hover(); - await page.getByRole("menuitem", { name: "Project" }).click(); + await openCreateProjectDialog(page); await page.getByTestId("create-project-name").fill("retry-project"); await page.getByTestId("create-project-submit").click(); @@ -510,8 +561,7 @@ test("project creation is idempotent after a lost publish acknowledgement", asyn await installMockBridge(page); await page.goto("/", { waitUntil: "domcontentloaded" }); await page.getByTestId("open-projects-view").click(); - await page.getByTestId("projects-create-menu").hover(); - await page.getByRole("menuitem", { name: "Project" }).click(); + await openCreateProjectDialog(page); await page.getByTestId("create-project-name").fill("lost-ack-project"); await page.getByTestId("create-project-submit").click(); @@ -731,6 +781,49 @@ test("latest files commit opens its detail without a divider", async ({ await expect(page.getByTestId("project-commit-detail")).toBeVisible(); }); +test("project workspace sheet enters at its settled width", async ({ + page, +}) => { + await enableProjectsFeature(page); + await installMockBridge(page); + await page.goto("/", { waitUntil: "domcontentloaded" }); + await page.getByTestId("open-projects-view").click(); + await openCreateProjectDialog(page); + await page.getByTestId("create-project-name").fill("sheet-motion-demo"); + await page.getByTestId("create-project-submit").click(); + await expect(page.getByTestId("project-channel-home")).toBeVisible(); + + const summaryColumn = page.getByTestId("project-home-summary-column"); + const resizeHandle = summaryColumn.getByTestId( + "right-auxiliary-pane-resize-handle", + ); + await expect(resizeHandle).toBeVisible(); + await resizeHandle.hover(); + await expect( + resizeHandle.getByTestId("right-auxiliary-pane-resize-indicator"), + ).toHaveCount(0); + const summaryRail = page.getByTestId("project-home-summary-rail"); + const openRailWidth = await summaryRail.evaluate( + (element) => element.getBoundingClientRect().width, + ); + expect(openRailWidth).toBeGreaterThan(0); + await page.getByTestId("project-home-context-tasks").click(); + await expect(page.getByTestId("project-home-workspace-sheet")).toBeVisible(); + + const collapsedRailWidth = await summaryRail.evaluate( + (element) => element.getBoundingClientRect().width, + ); + expect(collapsedRailWidth).toBeLessThanOrEqual(1); + const focusDrawer = page.getByTestId("focus-thread-drawer"); + const enteringDrawerWidth = (await focusDrawer.boundingBox())?.width ?? 0; + expect(enteringDrawerWidth).toBeGreaterThan(0); + await waitForAnimations(page); + const settledDrawerWidth = (await focusDrawer.boundingBox())?.width ?? 0; + expect( + Math.abs(settledDrawerWidth - enteringDrawerWidth), + ).toBeLessThanOrEqual(1); +}); + test("commit detail opens from the commits feed with a diff", async ({ page, }) => { @@ -753,8 +846,13 @@ test("commit detail opens from the commits feed with a diff", async ({ .first(); await expect(projectEntry).toBeVisible({ timeout: 10_000 }); await projectEntry.click(); + await page.getByTestId("project-home-context-repo-buzz").click(); + await page.getByTestId("project-workspace-back").click(); await page.getByTestId("project-home-context-tasks").click(); await expect(page.getByTestId("project-home-workspace-sheet")).toBeVisible(); + const focusDrawer = page.getByTestId("focus-thread-drawer"); + await expect(focusDrawer).toHaveCount(1); + await expect(focusDrawer).toHaveCSS("outline-style", "none"); const workspaceSheet = page.getByTestId("project-home-workspace-sheet"); await expect(workspaceSheet).toHaveAttribute("data-tab", "issues"); await expect( diff --git a/desktop/tests/e2e/project-pr-review.spec.ts b/desktop/tests/e2e/project-pr-review.spec.ts index 06a0b95e4fc..ace54e9fa47 100644 --- a/desktop/tests/e2e/project-pr-review.spec.ts +++ b/desktop/tests/e2e/project-pr-review.spec.ts @@ -1166,6 +1166,10 @@ test("sidebar distinguishes the Projects overview from an open project", async ( await projectsOverview.click(); await expect(projectsOverview).toHaveAttribute("data-active", "true"); await expect(sidebarProject).toHaveAttribute("data-active", "false"); + await expect(sidebarProject.getByTestId("project-channel-icon")).toHaveCSS( + "opacity", + "0.8", + ); await expect(sidebarProject.locator('[data-sidebar="menu-label"]')).toHaveCSS( "opacity", "0.8", @@ -1728,12 +1732,7 @@ test("project overview presents collapsible context beside grouped activity", as ).toBe(280); await expect( page.getByTestId("projects-overview-create-project"), - ).toContainText("Create project"); - await expect( - page - .getByTestId("projects-overview-context-panel") - .getByTestId("projects-create-menu"), - ).toBeVisible(); + ).toHaveCount(0); await expect(page.getByTestId("projects-overview-stats-pod")).toBeVisible(); await expect( page @@ -1803,17 +1802,12 @@ test("project overview presents collapsible context beside grouped activity", as const stats = page.getByTestId("projects-overview-stat"); await expect(stats).toHaveCount(5); await expect(stats.nth(2)).toContainText("Channels"); - const createBox = await page - .getByTestId("projects-overview-create-project") - .boundingBox(); const lastStatBox = await stats.last().boundingBox(); const peopleBox = await page .getByTestId("projects-overview-people") .boundingBox(); - expect(createBox).toBeTruthy(); expect(lastStatBox).toBeTruthy(); expect(peopleBox).toBeTruthy(); - expect(peopleBox?.y ?? 0).toBeGreaterThan(createBox?.y ?? 0); expect(peopleBox?.y ?? 0).toBeGreaterThan( (lastStatBox?.y ?? 0) + (lastStatBox?.height ?? 0) - 1, ); @@ -1839,9 +1833,13 @@ test("project overview presents collapsible context beside grouped activity", as await expect(page.getByTestId("projects-overview-context-title")).toHaveText( "Channels", ); + await expect(page.getByTestId("projects-overview-add-channel")).toBeVisible(); + await page.getByTestId("projects-overview-add-channel").click(); + await expect(page.getByTestId("create-project-channel-dialog")).toBeVisible(); await expect( - page.getByTestId("projects-overview-create-project"), - ).toHaveCount(0); + page.getByTestId("create-project-channel-project"), + ).toBeVisible(); + await page.keyboard.press("Escape"); await expect(page.getByTestId("projects-overview-people")).toHaveCount(0); await expect(page.getByTestId("projects-overview-activity")).toHaveCount(0); await expect(stats).toHaveCount(3); @@ -1902,8 +1900,14 @@ test("project overview presents collapsible context beside grouped activity", as "Repositories", ); await expect( - page.getByTestId("projects-overview-create-project"), - ).toHaveCount(0); + page.getByTestId("projects-overview-add-repository"), + ).toBeVisible(); + await page.getByTestId("projects-overview-add-repository").click(); + await expect(page.getByTestId("add-project-repository-dialog")).toBeVisible(); + await expect( + page.getByTestId("add-project-repository-project"), + ).toBeVisible(); + await page.keyboard.press("Escape"); await expect(stats.nth(1)).toContainText("Active tasks"); await expect(page.getByTestId("projects-overview-activity")).toHaveCount(0); await page.getByTestId("projects-section-issues").click(); @@ -1912,7 +1916,7 @@ test("project overview presents collapsible context beside grouped activity", as ); await expect( page.getByTestId("projects-overview-create-issue"), - ).toContainText("Create task"); + ).toHaveAttribute("aria-label", "Create task"); await expect(page.getByTestId("projects-overview-activity")).toHaveCount(0); await page.getByTestId("projects-section-prs").click(); await expect(page.getByTestId("projects-overview-context-title")).toHaveText( @@ -1920,7 +1924,7 @@ test("project overview presents collapsible context beside grouped activity", as ); await expect( page.getByTestId("projects-overview-create-pull-request"), - ).toContainText("Create review"); + ).toHaveAttribute("aria-label", "Create review"); await expect(page.getByTestId("projects-overview-people")).toBeVisible(); await expect(page.getByTestId("projects-overview-activity")).toHaveCount(0); await page.getByTestId("projects-section-all").click(); @@ -1932,6 +1936,7 @@ test("project overview presents collapsible context beside grouped activity", as ); await expect(page.getByTestId("projects-overview-activity")).toHaveCount(0); + await page.getByTestId("projects-section-projects").click(); await page.getByTestId("projects-overview-create-project").click(); await expect(page.getByTestId("create-project-dialog")).toBeVisible(); await page.keyboard.press("Escape"); @@ -1949,13 +1954,8 @@ test("project overview presents collapsible context beside grouped activity", as "data-project-context-detached", "true", ); - await expect(stats).toHaveCount(5); - await expect(activityCards.first()).toBeVisible(); - await expect( - page - .getByTestId("projects-workspace-chrome") - .getByTestId("projects-create-menu"), - ).toHaveCount(0); + await expect(stats).toHaveCount(3); + await expect(activityCards).toHaveCount(0); }); test("project overview chrome toggles a detached resizable agent chat", async ({ @@ -2103,7 +2103,7 @@ test("project overview chrome toggles a detached resizable agent chat", async ({ ).toBeVisible(); }); -test("project overview info control animates the context rail", async ({ +test("project overview drawer control animates the context rail", async ({ page, }) => { await enableProjectsFeature(page); @@ -2114,6 +2114,10 @@ test("project overview info control animates the context rail", async ({ const toggle = page.getByTestId("projects-overview-context-toggle"); const rail = page.getByTestId("projects-overview-context-rail"); const railPanel = page.getByTestId("projects-overview-context-rail-panel"); + const drawerIndicator = page + .getByTestId("projects-overview-context-icon") + .locator("rect") + .nth(1); const contentSurface = page.locator("[data-buzz-content-surface]"); await expect(page.getByTestId("projects-overview-layout")).toHaveAttribute( "data-project-context-detached", @@ -2133,6 +2137,7 @@ test("project overview info control animates the context rail", async ({ await expect( page.getByTestId("projects-overview-context-icon"), ).toBeVisible(); + await expect(drawerIndicator).toHaveAttribute("width", "5px"); await expect(rail).toHaveCSS("width", "288px"); await expect(page.getByTestId("projects-overview-layout")).toHaveCSS( "padding-right", @@ -2146,14 +2151,9 @@ test("project overview info control animates the context rail", async ({ await toggle.click(); await expect(rail).toHaveCSS("width", "0px"); await expect(toggle).toHaveAttribute("aria-pressed", "false"); + await expect(drawerIndicator).toHaveAttribute("width", "2px"); await expect(railPanel).toHaveCSS("transform", "none"); expect(await surfaceStyle()).toEqual(expandedSurfaceStyle); - await expect( - page - .getByTestId("projects-workspace-chrome") - .getByTestId("projects-create-menu"), - ).toHaveCount(0); - await toggle.click(); await expect(rail).toHaveCSS("width", "288px"); await expect(toggle).toHaveAttribute("aria-pressed", "true"); @@ -2225,7 +2225,7 @@ test("selecting overview list rows switches the context pod to the cluster", asy "1 task", ); await expect(page.getByTestId("projects-selection-summary")).toContainText( - "Selection", + "1 task", ); await expect(page.getByTestId("projects-selection-items")).toHaveCount(0); await expect(page.getByTestId("projects-selection-clear")).toBeVisible(); @@ -2914,7 +2914,14 @@ test("project detail content areas do not paint background fills", async ({ } }; - for (const tab of ["Files", "Commits", "Tasks", "Review", "Contributors"]) { + for (const tab of [ + "Overview", + "Files", + "Commits", + "Tasks", + "Review", + "Contributors", + ]) { await page.getByRole("tab", { name: tab, exact: true }).click(); await expectVisiblePanelsToBeTransparent(); } diff --git a/desktop/tests/e2e/projects-v3-screenshots.spec.ts b/desktop/tests/e2e/projects-v3-screenshots.spec.ts index 241fa44c6c0..9a52ae52d86 100644 --- a/desktop/tests/e2e/projects-v3-screenshots.spec.ts +++ b/desktop/tests/e2e/projects-v3-screenshots.spec.ts @@ -107,7 +107,7 @@ test("projects activity overview screenshot", async ({ page }) => { ).toBeVisible(); await expect( page.getByTestId("projects-overview-create-project"), - ).toBeVisible(); + ).toHaveCount(0); await expect( page.getByTestId("projects-activity-group").first(), ).toBeVisible(); @@ -170,6 +170,10 @@ test("sidebar project add flow browses before creating", async ({ page }) => { page.getByTestId("create-project-channel-permissions"), ).toBeVisible(); await expect(page.getByTestId("create-project-listing")).toHaveText("Listed"); + await expect(page.getByTestId("create-project-template")).toHaveText( + "Project home", + ); + await expect(page.getByTestId("create-project-team")).toHaveText("None"); await expect(page.getByTestId("create-project-agent")).toHaveText("None"); await page.getByRole("button", { name: "Back to projects" }).click(); await expect(browser).toBeVisible(); @@ -292,6 +296,7 @@ test("projects v3 workspace screenshot states", async ({ page }) => { // Borderless workspace: repository controls live in the persistent, // resizable auxiliary panel instead of a header row. const backButton = page.getByTestId("project-workspace-back"); + const overviewTab = page.getByRole("tab", { name: "Overview" }); const filesTab = page.getByRole("tab", { name: "Files" }); const channelsTab = page.getByRole("tab", { name: "Channels" }); const contributorsTab = page.getByRole("tab", { name: "Contributors" }); @@ -324,7 +329,11 @@ test("projects v3 workspace screenshot states", async ({ page }) => { (menuBox?.x ?? 0) + (menuBox?.width ?? 0), ); }; + await expect(overviewTab).toBeVisible(); await expect(filesTab).toBeVisible(); + expect((await overviewTab.boundingBox())?.x).toBeLessThan( + (await filesTab.boundingBox())?.x ?? 0, + ); await expect(backButton).toBeVisible(); await expect(page.getByTestId("app-sidebar")).toBeVisible(); await expect(projectDetailScroll).toHaveCSS("overscroll-behavior-y", "none");