diff --git a/src/pages/OnBoardingPage.tsx b/src/pages/OnBoardingPage.tsx
index cc6434a7..f3eda7d8 100644
--- a/src/pages/OnBoardingPage.tsx
+++ b/src/pages/OnBoardingPage.tsx
@@ -25,7 +25,6 @@ import { useQueryClient } from "@tanstack/react-query";
import { Link, useLocation, useNavigate, useParams } from "react-router-dom";
import { PageHeader } from "../components/layout/PageHeader";
import { PageShell } from "../components/layout/PageShell";
-import { AlertDialog } from "../components/ui/AlertDialog";
import { Badge } from "../components/ui/Badge";
import { Button } from "../components/ui/Button";
import { SegmentedTabs } from "../components/ui/SegmentedTabs";
@@ -221,7 +220,6 @@ export function OnBoardingPage() {
SLIDING_PANEL_EXIT_MS,
);
}, []);
- const [confirmRegenerate, setConfirmRegenerate] = useState(false);
const [isDinoActiveInGeneration, setIsDinoActiveInGeneration] = useState(false);
/**
* The last running generation, kept so the generation screen can stay up while the dino game
@@ -690,7 +688,6 @@ export function OnBoardingPage() {
const requestGeneration = () => {
if (!selectedProjectId) return;
- setConfirmRegenerate(false);
startGeneration(selectedProjectId);
};
@@ -799,26 +796,37 @@ export function OnBoardingPage() {
{/* Retrying a phase that was skipped for lack of material changes nothing, so when that
is all there is, the button says so rather than inviting the same answer. */}
-
- {retryCouldHelp(generationIssues)
- ? "Trying again re-runs assembly for every phase."
- : "Another run will produce the same result until the project has more material."}
-
+ {canManageSelected && (
+
+ {retryCouldHelp(generationIssues)
+ ? "Trying again re-runs assembly for every phase."
+ : "Another run will produce the same result until the project has more material."}
+
+ )}
) : null}
- }
- disabled={!selectedProjectId}
- >
- Try generation again
-
+ {/* Building an existing path again replaces it, which is the project manager's call: the
+ backend refuses it to members, so they are pointed at the PM instead of a button that
+ can only fail. */}
+ {canManageSelected ? (
+ }
+ disabled={!selectedProjectId}
+ >
+ Try generation again
+
+ ) : (
+
+ Your project manager can rebuild your path from your page in the team area.
+
+ )}
);
}
@@ -839,30 +847,20 @@ export function OnBoardingPage() {
? "You made it through every phase. Everything stays here to look back on."
: "Your path into the project. Phases that are open can be done in any order."
}
+ // Rebuilding a path throws the member's progress away, so it is the PM's call: it lives
+ // on the member's page in the PM area, not here.
actions={
- <>
- {generationIssues.length > 0 && (
-
-
-
- {generationIssues.length}
-
-
- )}
-
- >
+
+
+ {generationIssues.length}
+
+
+ )
}
/>
@@ -978,21 +976,6 @@ export function OnBoardingPage() {
)}
-
- setConfirmRegenerate(false)}
- onConfirm={() => {
- requestGeneration();
- toast.info("Rebuilding your onboarding path", {
- description: "This runs in the background; you can keep using the app.",
- });
- }}
- />
);
}
diff --git a/src/pages/TeamMemberDetailPage.tsx b/src/pages/TeamMemberDetailPage.tsx
index 495d89ed..c7dad9af 100644
--- a/src/pages/TeamMemberDetailPage.tsx
+++ b/src/pages/TeamMemberDetailPage.tsx
@@ -1,4 +1,13 @@
-import { MessageSquareText, Pencil, Plus, ThumbsDown, ThumbsUp, Users, X } from "lucide-react";
+import {
+ MessageSquareText,
+ Pencil,
+ Plus,
+ RefreshCw,
+ ThumbsDown,
+ ThumbsUp,
+ Users,
+ X,
+} from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { useToast } from "../context/useToast";
@@ -42,6 +51,23 @@ type DetailOnboardingStep = OnboardingStepEndpoint & {
} | null;
};
+/** A rebuild failure, in the PM's words rather than the member's. */
+function describeRebuildError(message: string, reason?: string): string {
+ if (reason === "not-enough-knowledge") {
+ return "The project's knowledge base does not cover any phase yet. The current path is unchanged.";
+ }
+ if (/no active blueprint/i.test(message)) {
+ return "This project has no published onboarding blueprint yet. Publish one, then try again.";
+ }
+ if (/multiple active blueprints/i.test(message)) {
+ return "This project has more than one published onboarding blueprint. Archive all but one, then try again.";
+ }
+ if (/status: 403/.test(message)) {
+ return "Only the project's manager can rebuild paths, and only for members of the project.";
+ }
+ return `${message || "The generation failed."} The current path is unchanged.`;
+}
+
function getElapsedDays(startedAt: string): number {
const started = new Date(startedAt).getTime();
@@ -56,6 +82,8 @@ import { MemberDetailDialogs } from "../features/team-management/components/deta
import { MemberGapsPanel } from "../features/team-management/components/detail/MemberGapsPanel";
import { MemberJourneySection } from "../features/team-management/components/detail/MemberJourneySection";
import { AlertDialog } from "../components/ui/AlertDialog";
+import { Button } from "../components/ui/Button";
+import { onboardingService } from "../services/onboardingService";
import {
PhaseCheckAdminModal,
type PhaseCheckAdminTab,
@@ -158,6 +186,13 @@ export function TeamMemberDetailPage() {
const [feedbackError, setFeedbackError] = useState("");
const [loadError, setLoadError] = useState("");
const toast = useToast();
+ // Rebuilding a member's path is the PM's call alone (members can only build their first one),
+ // so the control lives here rather than on the member's onboarding page.
+ const [confirmRebuild, setConfirmRebuild] = useState(false);
+ const [rebuildingUserId, setRebuildingUserId] = useState(null);
+ // Leaving the page only stops watching; the generation carries on on the backend.
+ const rebuildWatch = useRef(null);
+ useEffect(() => () => rebuildWatch.current?.abort(), []);
useEffect(() => {
// A response for the member (or project) this page has since moved away from is dropped: it
@@ -592,6 +627,60 @@ export function TeamMemberDetailPage() {
}
}
+ function handleRebuildPath() {
+ if (!user || !selectedProjectId || rebuildingUserId) return;
+ const memberId = user.userId;
+ const firstName = user.firstname;
+ const controller = new AbortController();
+ rebuildWatch.current?.abort();
+ rebuildWatch.current = controller;
+ setConfirmRebuild(false);
+ setRebuildingUserId(memberId);
+ toast.info(`Rebuilding ${firstName}'s onboarding path`, {
+ description: "This runs in the background and takes a few minutes.",
+ });
+
+ const finish = () => {
+ if (rebuildWatch.current === controller) rebuildWatch.current = null;
+ setRebuildingUserId((current) => (current === memberId ? null : current));
+ };
+
+ void onboardingService
+ .rebuildMemberPath(
+ selectedProjectId,
+ memberId,
+ {
+ onPath: () => {},
+ onDone: () => {
+ finish();
+ if (shownUserId.current !== memberId) return;
+ toast.success(`${firstName}'s onboarding path was rebuilt`);
+ void Promise.all([refreshOnboardingPath(), refreshMember()]);
+ },
+ onError: (message, reason) => {
+ finish();
+ toast.error(`${firstName}'s path could not be rebuilt`, {
+ description: describeRebuildError(message, reason),
+ });
+ },
+ onInterrupted: () => {
+ finish();
+ toast.info("Lost track of the rebuild", {
+ description: "It keeps running on the server. Reload the page in a few minutes.",
+ });
+ },
+ },
+ controller.signal,
+ )
+ .catch((error: unknown) => {
+ finish();
+ if (controller.signal.aborted) return;
+ toast.error(`${firstName}'s path could not be rebuilt`, {
+ description: error instanceof Error ? error.message : undefined,
+ });
+ });
+ }
+
function goBack() {
if (typeof window !== "undefined" && window.history.length > 1) {
void navigate(-1);
@@ -706,6 +795,22 @@ export function TeamMemberDetailPage() {
title={`${user.firstname} ${user.lastname}`}
subtitle={user.currentStep?.title || "Onboarding completed"}
back={{ label: "Back", onClick: goBack }}
+ actions={
+
+ }
mainClassName="pt-6 pb-24 lg:pt-8"
bandExtra={
@@ -1050,6 +1155,15 @@ export function TeamMemberDetailPage() {
onClose={() => setCheckModal(null)}
/>
)}
+ setConfirmRebuild(false)}
+ onConfirm={handleRebuildPath}
+ />
{
+ try {
+ if (keycloak.authenticated) {
+ await keycloak.updateToken(30);
+ }
+ } catch (error) {
+ console.error("Failed to refresh Keycloak token for onboarding personalize", error);
+ void keycloak.login();
+ return;
+ }
+
+ const res = await fetch(url, {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${keycloak.token}`,
+ },
+ signal,
+ });
+
+ if (!res.ok) {
+ handlers.onError?.(`HTTP error! status: ${res.status}`);
+ return;
+ }
+
+ const stream = res.body;
+ if (!stream) {
+ throw new Error("No response stream");
+ }
+
+ for await (const event of parseSSEStream(stream)) {
+ switch (event.type) {
+ case "stage":
+ handlers.onStage?.(event.name ?? "", event.detail);
+ break;
+ case "path":
+ if (event.path) {
+ handlers.onPath(event.path);
+ }
+ break;
+ case "done":
+ handlers.onDone();
+ return;
+ case "error":
+ handlers.onError?.(event.message ?? "Unknown error", event.reason ?? undefined);
+ return;
+ }
+ }
+
+ // Falling out of the loop means the body ended without `done` or `error`, which the backend
+ // never does on purpose. Reporting a finished path here told members their path was ready
+ // while it was still being built.
+ handlers.onInterrupted?.();
+}
+
/**
* Onboarding path, step, question and task CRUD.
* Streams AI path generation over SSE; falls back to mock data on fetch
@@ -50,57 +112,32 @@ export const onboardingService = {
handlers: OnboardingPersonalizeHandlers,
signal?: AbortSignal,
): Promise {
- try {
- if (keycloak.authenticated) {
- await keycloak.updateToken(30);
- }
- } catch (error) {
- console.error("Failed to refresh Keycloak token for onboarding personalize", error);
- void keycloak.login();
- return;
- }
-
- const res = await fetch(`/api/v1/projects/${projectId}/onboarding/me/path/personalize`, {
- method: "POST",
- headers: {
- Authorization: `Bearer ${keycloak.token}`,
- },
+ await streamPathGeneration(
+ `/api/v1/projects/${projectId}/onboarding/me/path/personalize`,
+ handlers,
signal,
- });
-
- if (!res.ok) {
- handlers.onError?.(`HTTP error! status: ${res.status}`);
- return;
- }
-
- const stream = res.body;
- if (!stream) {
- throw new Error("No response stream");
- }
-
- for await (const event of parseSSEStream(stream)) {
- switch (event.type) {
- case "stage":
- handlers.onStage?.(event.name ?? "", event.detail);
- break;
- case "path":
- if (event.path) {
- handlers.onPath(event.path);
- }
- break;
- case "done":
- handlers.onDone();
- return;
- case "error":
- handlers.onError?.(event.message ?? "Unknown error", event.reason ?? undefined);
- return;
- }
- }
+ );
+ },
- // Falling out of the loop means the body ended without `done` or `error`, which the backend
- // never does on purpose. Reporting a finished path here told members their path was ready
- // while it was still being built.
- handlers.onInterrupted?.();
+ /**
+ * Rebuilds a member's onboarding path from the project's active blueprint -- the PM's side of
+ * `personalizePath`, which members may only use for their first path. Replaces the member's
+ * path, and their progress with it, once the generation finishes.
+ *
+ * Like `personalizePath`, the generation runs on the backend independently of this stream: the
+ * member's own onboarding page attaches to it, and aborting here only stops watching.
+ */
+ async rebuildMemberPath(
+ projectId: string,
+ userId: string,
+ handlers: OnboardingPersonalizeHandlers,
+ signal?: AbortSignal,
+ ): Promise {
+ await streamPathGeneration(
+ `/api/v1/projects/${projectId}/onboarding/users/${userId}/path/personalize`,
+ handlers,
+ signal,
+ );
},
/**
diff --git a/tests/unit/pages/OnBoardingPage.test.tsx b/tests/unit/pages/OnBoardingPage.test.tsx
index 1483a5f0..40e90839 100644
--- a/tests/unit/pages/OnBoardingPage.test.tsx
+++ b/tests/unit/pages/OnBoardingPage.test.tsx
@@ -18,6 +18,7 @@ const { projectContextState } = vi.hoisted(() => ({
selectedProjectId: "proj1",
isLoading: false,
isSwitcherEnabled: true,
+ canManageSelected: false,
},
}));
@@ -73,6 +74,7 @@ vi.mock("../../../src/features/projects/useProjectContext", async () => {
: null,
isLoading: projectContextState.isLoading,
isSwitcherEnabled: projectContextState.isSwitcherEnabled,
+ canManageSelected: projectContextState.canManageSelected,
}),
};
});
@@ -162,6 +164,7 @@ describe("OnBoardingPage", () => {
projectContextState.selectedProjectId = "proj1";
projectContextState.isLoading = false;
projectContextState.isSwitcherEnabled = true;
+ projectContextState.canManageSelected = false;
});
it("renders loading state initially", () => {
@@ -294,6 +297,8 @@ describe("OnBoardingPage", () => {
expect(screen.getAllByText("Phase 1").length).toBeGreaterThan(0);
expect(screen.getAllByText("Step 1").length).toBeGreaterThan(0);
+ // Rebuilding throws the member's progress away: that is the PM's call, from the PM area.
+ expect(screen.queryByRole("button", { name: /rebuild|regenerate/i })).not.toBeInTheDocument();
});
it("computes progress correctly", async () => {
@@ -421,7 +426,8 @@ describe("OnBoardingPage", () => {
expect(screen.getByTitle("Role-specific tasks — Could not be reached")).toBeInTheDocument();
});
- it("offers regeneration when every generated phase is hidden", async () => {
+ it("offers the project's manager regeneration when every generated phase is hidden", async () => {
+ projectContextState.canManageSelected = true;
server.use(
http.get("/api/v1/onboarding/me/path", () =>
HttpResponse.json({
@@ -482,7 +488,33 @@ describe("OnBoardingPage", () => {
expect(screen.getByTitle("Architecture — Took too long")).toBeInTheDocument();
});
+ it("points a member at their PM instead of rebuilding a path whose phases are all hidden", async () => {
+ server.use(
+ http.get("/api/v1/onboarding/me/path", () =>
+ HttpResponse.json({
+ id: "path1",
+ userId: "user1",
+ createdAt: new Date().toISOString(),
+ phases: [],
+ generationIssues: [{ phaseId: "phase1", title: "Role-specific tasks", status: "EMPTY" }],
+ }),
+ ),
+ );
+
+ render(
+
+
+ ,
+ );
+
+ expect(await screen.findByText("No onboarding phases were generated")).toBeInTheDocument();
+ // Replacing an existing path is the PM's call; the backend refuses it to members.
+ expect(screen.queryByRole("button", { name: "Try generation again" })).not.toBeInTheDocument();
+ expect(screen.getByText(/Your project manager can rebuild your path/)).toBeInTheDocument();
+ });
+
it("warns that every phase timed out and offers regeneration", async () => {
+ projectContextState.canManageSelected = true;
server.use(
http.get("/api/v1/onboarding/me/path", () =>
HttpResponse.json({
diff --git a/tests/unit/pages/TeamMemberDetailPage.test.tsx b/tests/unit/pages/TeamMemberDetailPage.test.tsx
index da759019..6570bb91 100644
--- a/tests/unit/pages/TeamMemberDetailPage.test.tsx
+++ b/tests/unit/pages/TeamMemberDetailPage.test.tsx
@@ -1,4 +1,4 @@
-import { render, screen, waitFor } from "@testing-library/react";
+import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { MemoryRouter } from "react-router-dom";
@@ -6,6 +6,11 @@ import { TeamMemberDetailPage } from "../../../src/pages/TeamMemberDetailPage";
import type { TeamOverviewUser, ProjectRole } from "../../../src/features/team-management/types";
import type { KnowledgeGap } from "../../../src/features/knowledge-gaps/types";
import { knowledgeGapService } from "../../../src/services/knowledgeGapService";
+import { onboardingService } from "../../../src/services/onboardingService";
+
+vi.mock("../../../src/services/onboardingService", () => ({
+ onboardingService: { rebuildMemberPath: vi.fn() },
+}));
vi.mock("../../../src/context/useAuth", () => ({
useAuth: () => ({ profile: { id: "pm1", firstName: "PM", lastName: "User" } }),
@@ -335,6 +340,39 @@ describe("TeamMemberDetailPage", () => {
// The knowledge-gaps overview is the project's full component roster now, but
// this panel is headed "Knowledge gaps" -- listing repositories that are
// missing nothing would overstate what the member has to answer for.
+ it("lets the PM rebuild the member's path after confirming", async () => {
+ vi.mocked(onboardingService.rebuildMemberPath).mockImplementation(
+ (_projectId, _userId, handlers) => {
+ handlers.onDone();
+ return Promise.resolve();
+ },
+ );
+ const user = userEvent.setup();
+ render(
+
+
+ ,
+ );
+
+ await user.click(await screen.findByRole("button", { name: /Rebuild path/ }));
+ expect(onboardingService.rebuildMemberPath).not.toHaveBeenCalled();
+
+ await user.click(
+ within(await screen.findByRole("alertdialog")).getByRole("button", { name: "Rebuild path" }),
+ );
+
+ await waitFor(() => {
+ expect(onboardingService.rebuildMemberPath).toHaveBeenCalledWith(
+ "proj1",
+ "user1",
+ expect.any(Object),
+ expect.any(AbortSignal),
+ );
+ });
+ // The finished rebuild is read back, so the journey shows the new path.
+ await waitFor(() => expect(mockGetUserOnboardingPath).toHaveBeenCalledTimes(2));
+ });
+
it("keeps covered components out of the member's gaps panel", async () => {
const gap = (component: string, severity: KnowledgeGap["severity"]): KnowledgeGap => ({
id: component,