From 69a30f2ea1b79e21684e26fcafc6e4a304bc695e Mon Sep 17 00:00:00 2001 From: Wrench <0eabe6ea5758c1e4c5b68cea4ac42b32c479072883cb28da8110b4a47c32b9a1@buzz.block.builderlab.xyz> Date: Mon, 24 Aug 2026 00:25:08 -0400 Subject: [PATCH] fix(projects): surface partial repository failures Co-authored-by: Wrench <0eabe6ea5758c1e4c5b68cea4ac42b32c479072883cb28da8110b4a47c32b9a1@buzz.block.builderlab.xyz> Signed-off-by: Wrench <0eabe6ea5758c1e4c5b68cea4ac42b32c479072883cb28da8110b4a47c32b9a1@buzz.block.builderlab.xyz> --- desktop/src/features/projects/hooks.ts | 8 +- .../projects/lib/projectHomeTemplate.test.mjs | 37 +++++ .../ui/ProjectHomeCommitsPanel.test.mjs | 82 +++++++++++ .../projects/ui/ProjectHomeCommitsPanel.tsx | 44 ++++-- .../ui/useCreateProjectFormSettings.test.mjs | 44 ++++++ .../ui/useCreateProjectFormSettings.ts | 127 +++++++++++------- .../projects/useProjectRepositorySnapshots.ts | 11 +- 7 files changed, 282 insertions(+), 71 deletions(-) create mode 100644 desktop/src/features/projects/ui/ProjectHomeCommitsPanel.test.mjs create mode 100644 desktop/src/features/projects/ui/useCreateProjectFormSettings.test.mjs diff --git a/desktop/src/features/projects/hooks.ts b/desktop/src/features/projects/hooks.ts index 4e26287f65..1e397c7141 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 index e10148f183..6f6ebfd8f5 100644 --- a/desktop/src/features/projects/lib/projectHomeTemplate.test.mjs +++ b/desktop/src/features/projects/lib/projectHomeTemplate.test.mjs @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import { test } from "node:test"; import { + applyProjectHomeCanvas, PROJECT_HOME_CHANNEL_TEMPLATE, PROJECT_HOME_TEMPLATE_ID, renderProjectHomeCanvas, @@ -13,6 +14,42 @@ test("project home is the built-in default project template", () => { 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", diff --git a/desktop/src/features/projects/ui/ProjectHomeCommitsPanel.test.mjs b/desktop/src/features/projects/ui/ProjectHomeCommitsPanel.test.mjs new file mode 100644 index 0000000000..3a4272d60b --- /dev/null +++ b/desktop/src/features/projects/ui/ProjectHomeCommitsPanel.test.mjs @@ -0,0 +1,82 @@ +import assert from "node:assert/strict"; +import { after, before, test } from "node:test"; + +import { JSDOM } from "jsdom"; + +const dom = new JSDOM("", { + 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(); + } +}); diff --git a/desktop/src/features/projects/ui/ProjectHomeCommitsPanel.tsx b/desktop/src/features/projects/ui/ProjectHomeCommitsPanel.tsx index 3f35755ead..9876c0fcbb 100644 --- a/desktop/src/features/projects/ui/ProjectHomeCommitsPanel.tsx +++ b/desktop/src/features/projects/ui/ProjectHomeCommitsPanel.tsx @@ -1,4 +1,5 @@ import type { ProjectPullRequest, Repository } from "@/features/projects/hooks"; +import { AlertTriangle } from "lucide-react"; import { projectRepoUnavailablePresentation, projectRepoUnavailableReason, @@ -72,18 +73,35 @@ export function ProjectHomeCommitsPanel({ 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/useCreateProjectFormSettings.test.mjs b/desktop/src/features/projects/ui/useCreateProjectFormSettings.test.mjs new file mode 100644 index 0000000000..85294b6393 --- /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 442a2b19da..b109949616 100644 --- a/desktop/src/features/projects/ui/useCreateProjectFormSettings.ts +++ b/desktop/src/features/projects/ui/useCreateProjectFormSettings.ts @@ -18,7 +18,70 @@ import { PROJECT_HOME_TEMPLATE_ID, } from "@/features/projects/lib/projectHomeTemplate"; import type { ProjectListingVisibility } from "@/features/projects/projectCreation"; -import type { ChannelTemplate, ChannelVisibility } from "@/shared/api/types"; +import type { + AcpRuntime, + AgentPersona, + AgentTeam, + ChannelTemplate, + ChannelVisibility, +} from "@/shared/api/types"; + +/** 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, @@ -85,57 +148,17 @@ export function useCreateProjectFormSettings( } }, [templateId, templates]); - const buildAgents = - React.useCallback((): CreateChannelManagedAgentInput[] => { - const defaultRuntime = runtimesQuery.data[0] ?? null; - const inputs: CreateChannelManagedAgentInput[] = []; - const seenPersonaIds = new Set(); - const addPersona = ( - persona: (typeof personas)[number], - selectedTeamId?: string, - ) => { - if (seenPersonaIds.has(persona.id)) return; - 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 agents.", - ); - } - seenPersonaIds.add(persona.id); - inputs.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 (teamId) { - const team = teams.find((entry) => entry.id === teamId); - if (!team) throw new Error("Choose a team that still exists."); - const resolution = resolveTeamPersonas(team, personas); - for (const persona of resolution.resolvedPersonas) { - addPersona(persona, team.id); - } - } - if (agentPersonaId) { - const persona = personas.find((entry) => entry.id === agentPersonaId); - if (!persona) throw new Error("Choose an agent that still exists."); - addPersona(persona); - } - return inputs; - }, [agentPersonaId, personas, runtimesQuery.data, teamId, teams]); + const buildAgents = React.useCallback( + () => + buildCreateProjectAgents({ + agentPersonaId, + personas, + runtimes: runtimesQuery.data, + teamId, + teams, + }), + [agentPersonaId, personas, runtimesQuery.data, teamId, teams], + ); const applyTemplate = React.useCallback( (template: ChannelTemplate) => { diff --git a/desktop/src/features/projects/useProjectRepositorySnapshots.ts b/desktop/src/features/projects/useProjectRepositorySnapshots.ts index 1877506bcd..1470772a48 100644 --- a/desktop/src/features/projects/useProjectRepositorySnapshots.ts +++ b/desktop/src/features/projects/useProjectRepositorySnapshots.ts @@ -4,6 +4,8 @@ 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 = { @@ -24,10 +26,15 @@ export function useProjectRepositorySnapshots( 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: repository.defaultBranch, + baseBranch: defaultBranch, cloneUrl, - defaultBranch: repository.defaultBranch, + defaultBranch, }); }, queryKey: [