Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 41 additions & 58 deletions src/pages/OnBoardingPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -690,7 +688,6 @@ export function OnBoardingPage() {

const requestGeneration = () => {
if (!selectedProjectId) return;
setConfirmRegenerate(false);
startGeneration(selectedProjectId);
};

Expand Down Expand Up @@ -799,26 +796,37 @@ export function OnBoardingPage() {
<GenerationIssueSummary issues={generationIssues} />
{/* 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. */}
<p className="mt-5 text-xs text-app-text-subtle">
{retryCouldHelp(generationIssues)
? "Trying again re-runs assembly for every phase."
: "Another run will produce the same result until the project has more material."}
</p>
{canManageSelected && (
<p className="mt-5 text-xs text-app-text-subtle">
{retryCouldHelp(generationIssues)
? "Trying again re-runs assembly for every phase."
: "Another run will produce the same result until the project has more material."}
</p>
)}
</div>
) : null}
<Button
className="mt-6"
variant={
generationIssues.length === 0 || retryCouldHelp(generationIssues)
? "primary"
: "secondary"
}
onClick={requestGeneration}
icon={<RefreshCw className="h-4 w-4" />}
disabled={!selectedProjectId}
>
Try generation again
</Button>
{/* 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 ? (
<Button
className="mt-6"
variant={
generationIssues.length === 0 || retryCouldHelp(generationIssues)
? "primary"
: "secondary"
}
onClick={requestGeneration}
icon={<RefreshCw className="h-4 w-4" />}
disabled={!selectedProjectId}
>
Try generation again
</Button>
) : (
<p className="mt-6 max-w-lg text-sm text-app-text-muted">
Your project manager can rebuild your path from your page in the team area.
</p>
)}
</CenteredState>
);
}
Expand All @@ -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 && (
<span
role="status"
aria-label={`${generationIssues.length} onboarding ${generationIssues.length === 1 ? "phase" : "phases"} could not be generated`}
>
<Badge variant="warning" size="sm" title={generationIssueSummary}>
<AlertTriangle className="mr-1 h-3.5 w-3.5" aria-hidden="true" />
{generationIssues.length}
</Badge>
</span>
)}
<Button
variant="secondary"
onClick={() => setConfirmRegenerate(true)}
icon={<RefreshCw className="h-4 w-4" />}
aria-label="Regenerate path with AI"
title="Regenerate path with AI"
disabled={!selectedProjectId}
generationIssues.length > 0 && (
<span
role="status"
aria-label={`${generationIssues.length} onboarding ${generationIssues.length === 1 ? "phase" : "phases"} could not be generated`}
>
Rebuild
</Button>
</>
<Badge variant="warning" size="sm" title={generationIssueSummary}>
<AlertTriangle className="mr-1 h-3.5 w-3.5" aria-hidden="true" />
{generationIssues.length}
</Badge>
</span>
)
}
/>
</div>
Expand Down Expand Up @@ -978,21 +976,6 @@ export function OnBoardingPage() {
)}
</SlidingTabPanel>
</main>

<AlertDialog
isOpen={confirmRegenerate}
title="Rebuild your onboarding path?"
description="Your path is put together again from the project's current blueprint and knowledge base. Progress on the current path is replaced."
confirmLabel="Rebuild path"
variant="danger"
onClose={() => setConfirmRegenerate(false)}
onConfirm={() => {
requestGeneration();
toast.info("Rebuilding your onboarding path", {
description: "This runs in the background; you can keep using the app.",
});
}}
/>
</div>
);
}
Expand Down
116 changes: 115 additions & 1 deletion src/pages/TeamMemberDetailPage.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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();

Expand All @@ -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,
Expand Down Expand Up @@ -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<string | null>(null);
// Leaving the page only stops watching; the generation carries on on the backend.
const rebuildWatch = useRef<AbortController | null>(null);
useEffect(() => () => rebuildWatch.current?.abort(), []);

useEffect(() => {
// A response for the member (or project) this page has since moved away from is dropped: it
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -706,6 +795,22 @@ export function TeamMemberDetailPage() {
title={`${user.firstname} ${user.lastname}`}
subtitle={user.currentStep?.title || "Onboarding completed"}
back={{ label: "Back", onClick: goBack }}
actions={
<Button
variant="secondary"
onClick={() => setConfirmRebuild(true)}
icon={<RefreshCw className="h-4 w-4" />}
loading={rebuildingUserId === user.userId}
disabled={!selectedProjectId || rebuildingUserId !== null}
title={`Rebuild ${user.firstname}'s onboarding path with AI`}
>
{rebuildingUserId === user.userId
? "Rebuilding…"
: onboardingPath
? "Rebuild path"
: "Build path"}
</Button>
}
mainClassName="pt-6 pb-24 lg:pt-8"
bandExtra={
<div>
Expand Down Expand Up @@ -1050,6 +1155,15 @@ export function TeamMemberDetailPage() {
onClose={() => setCheckModal(null)}
/>
)}
<AlertDialog
isOpen={confirmRebuild}
title={`Rebuild ${user.firstname}'s onboarding path?`}
description={`The path is put together again from the project's current blueprint and knowledge base. ${user.firstname}'s progress on the current path is replaced.`}
confirmLabel="Rebuild path"
variant="danger"
onClose={() => setConfirmRebuild(false)}
onConfirm={handleRebuildPath}
/>
<AlertDialog
isOpen={graphStepToDelete !== null}
title="Delete this step?"
Expand Down
Loading
Loading