diff --git a/src/App.tsx b/src/App.tsx
index d6b73a6d4..fd836cba0 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -22,6 +22,7 @@ import { EggEffectsLayer } from "./features/easter-eggs/components/EggEffectsLay
import { MyKnowledgeGapsProvider } from "./features/knowledge-gaps/MyKnowledgeGapsProvider";
import { KnowledgeGapOwnerAnnouncement } from "./features/knowledge-gaps/components/KnowledgeGapOwnerAnnouncement";
import { useScrollRestoration } from "./hooks/useScrollRestoration";
+import { useBuddyPathSync } from "./features/buddy/hooks/useBuddyPathSync";
import { GlobalShortcuts } from "./features/shortcuts";
function AppContent() {
@@ -29,6 +30,7 @@ function AppContent() {
const { showRocketPet } = useMoments();
const { isFocused } = useFocusMode();
useScrollRestoration();
+ useBuddyPathSync();
// Signed in at all — the shell is drawn for anyone past the login screen, onboarding included.
// `signingOut` stays out on purpose: it is the boot script's "this load is a logout return"
diff --git a/src/features/attestation/types.ts b/src/features/attestation/types.ts
index e833f454f..079ca0d49 100644
--- a/src/features/attestation/types.ts
+++ b/src/features/attestation/types.ts
@@ -5,7 +5,7 @@ export type AttestationState = "REQUESTED" | "ACCEPTED" | "WITHDRAWN";
* One request for a named colleague to confirm a hire's work.
*
* `returnedCount` is shown rather than hidden: work that took three passes is not the same as work
- * that took none, and the autonomy milestone reads exactly this number.
+ * that took none.
*/
export interface Attestation {
id: string;
diff --git a/src/features/board/components/BoardGrid.tsx b/src/features/board/components/BoardGrid.tsx
index 971efc90e..b2882b085 100644
--- a/src/features/board/components/BoardGrid.tsx
+++ b/src/features/board/components/BoardGrid.tsx
@@ -28,7 +28,6 @@ import { NoteCard } from "./NoteCard";
import { ArrivalStepsCard } from "./ArrivalStepsCard";
import { OpenPullRequestsCard } from "./OpenPullRequestsCard";
import { PathStepCard } from "./PathStepCard";
-import { PathToFirstContributionCard } from "./PathToFirstContributionCard";
import { SuggestedTasksCard } from "./SuggestedTasksCard";
import { TaskPoolCard } from "./TaskPoolCard";
import { BoardCardContext } from "./boardCardControls";
@@ -494,8 +493,6 @@ function BoardCardView({
// somebody to wire one up later and wonder why nothing shows.
const props = { card, ...shared };
switch (card.content.kind) {
- case "PATH_TO_FIRST_CONTRIBUTION":
- return ;
case "ARRIVAL_STEPS":
return ;
case "OPEN_PULL_REQUESTS":
diff --git a/src/features/board/components/BoardPathNotes.tsx b/src/features/board/components/BoardPathNotes.tsx
deleted file mode 100644
index d84bb316b..000000000
--- a/src/features/board/components/BoardPathNotes.tsx
+++ /dev/null
@@ -1,49 +0,0 @@
-import { AlertTriangle, CheckCircle2 } from "lucide-react";
-import { CONTRIBUTION_WORDING } from "../../../config/contributionWording";
-import { formatMoment } from "../../onboarding-metrics/format";
-import type { PathToFirstContributionContent } from "../types";
-
-/**
- * The two things the path card says that nothing else on the board does.
- *
- * The card's strip of moments went when {@link BoardPathWindow} took over the "where am I" job —
- * the board answers how far along somebody is in four other places, and five labelled dots above
- * it were a fifth. These two lines are not that: they are hire-facing facts with no second home.
- *
- * The stall reason is shown to the person in the stall, not only to their PM: a stall only
- * somebody else can see is a stall only somebody else can fix. Autonomy is the day onboarding
- * ended, which is worth saying plainly once rather than leaving as a state the board drops out of.
- *
- * Both are single lines rather than panels — the header of a board somebody is working down stays
- * quiet even when it has something to say.
- */
-export function BoardPathNotes({ content }: { content: PathToFirstContributionContent }) {
- const { autonomyReachedAt, stalledReason } = content;
-
- if (!stalledReason && !autonomyReachedAt) return null;
-
- return (
-
- {stalledReason && (
-
-
-
- Something is waiting: {stalledReason}. Ask your buddy about it — this is the kind of
- thing a person unblocks in a minute.
-
-
- )}
-
- {autonomyReachedAt && (
-
-
-
- You worked unsupervised here on {formatMoment(autonomyReachedAt)} — a{" "}
- {CONTRIBUTION_WORDING.noun} accepted with no rework and no one stepping in. Onboarding
- ended that day.
-
-
- )}
-
- );
-}
diff --git a/src/features/board/components/BoardPathWindow.tsx b/src/features/board/components/BoardPathWindow.tsx
index 106a8d446..03a333e6c 100644
--- a/src/features/board/components/BoardPathWindow.tsx
+++ b/src/features/board/components/BoardPathWindow.tsx
@@ -15,6 +15,7 @@ import {
type PathWindowState,
} from "../../onboarding/pathWindow.ts";
import { onboardingService } from "../../../services/onboardingService.ts";
+import { onBuddyPathChanged } from "../../buddy/aiBuddyBus.ts";
/** What each state is called and wears. Never colour alone — every one carries a word and a glyph. */
const STATES: Record<
@@ -78,17 +79,24 @@ export function BoardPathWindow({
useEffect(() => {
let cancelled = false;
- void onboardingService
- .fetchPath()
- .then((path) => {
- if (!cancelled) setWhere(pathWindow(path));
- })
- // No path, or no reaching it: the board has plenty else to show, and a strip that cannot
- // say where somebody is should not say anything at all.
- .catch(() => undefined);
+ const read = () =>
+ void onboardingService
+ .fetchPath()
+ .then((path) => {
+ if (!cancelled) setWhere(pathWindow(path));
+ })
+ // No path, or no reaching it: the board has plenty else to show, and a strip that cannot
+ // say where somebody is should not say anything at all.
+ .catch(() => undefined);
+
+ read();
+ // Read again when the buddy moved the path on, so the strip under the dock says where the
+ // hire now stands.
+ const unsubscribe = onBuddyPathChanged(read);
return () => {
cancelled = true;
+ unsubscribe();
};
}, []);
diff --git a/src/features/board/components/CardEditHistory.tsx b/src/features/board/components/CardEditHistory.tsx
index b48c74c54..75fcdaaa2 100644
--- a/src/features/board/components/CardEditHistory.tsx
+++ b/src/features/board/components/CardEditHistory.tsx
@@ -1,7 +1,7 @@
import { useState } from "react";
import { Check, ListChecks, Pencil, Undo2 } from "lucide-react";
import { Button } from "../../../components/ui/Button";
-import { readableTitle } from "../generation/pathToCards";
+import { readableTitle } from "../layout/cardNames";
import { useCardMarks } from "../marks/useCardMarks";
import { Marked } from "./Marked";
import type {
diff --git a/src/features/board/components/ChecklistCard.tsx b/src/features/board/components/ChecklistCard.tsx
index a535a7015..06b996d92 100644
--- a/src/features/board/components/ChecklistCard.tsx
+++ b/src/features/board/components/ChecklistCard.tsx
@@ -4,7 +4,7 @@ import { Button } from "../../../components/ui/Button";
import { EmptyState } from "../../../components/ui/EmptyState";
import { Input } from "../../../components/ui/Input";
import { SelectionCheckbox } from "../../admin/components/SelectionCheckbox";
-import { readableTitle } from "../generation/pathToCards";
+import { readableTitle } from "../layout/cardNames";
import { useBoardCardControls } from "./boardCardControls";
import { BoardCardFrame } from "./BoardCardFrame";
import { CardOriginLink } from "./CardOriginLink";
@@ -152,8 +152,8 @@ export function ChecklistCard({
return (
!item.done).length,
marks.map((mark) => mark.text),
diff --git a/src/features/board/components/CurrentTaskCard.tsx b/src/features/board/components/CurrentTaskCard.tsx
index db71e99d4..969490601 100644
--- a/src/features/board/components/CurrentTaskCard.tsx
+++ b/src/features/board/components/CurrentTaskCard.tsx
@@ -54,15 +54,7 @@ export function CurrentTaskCard({
icon={Target}
title="What you're working on"
card={card}
- subtitle={
- hasTask
- ? closed
- ? "Closed where it lives"
- : content.chosen
- ? "You picked this one"
- : "Handed to you as a first task"
- : undefined
- }
+ subtitle={hasTask ? (closed ? "Closed where it lives" : "You picked this one") : undefined}
onDismiss={onDismiss}
dismissing={dismissing}
>
diff --git a/src/features/board/components/PathToFirstContributionCard.tsx b/src/features/board/components/PathToFirstContributionCard.tsx
deleted file mode 100644
index 740c188d2..000000000
--- a/src/features/board/components/PathToFirstContributionCard.tsx
+++ /dev/null
@@ -1,97 +0,0 @@
-import { CONTRIBUTION_WORDING } from "../../../config/contributionWording";
-import { AlertTriangle, CheckCircle2, Route } from "lucide-react";
-import { formatMoment } from "../../onboarding-metrics/format";
-import { momentLabel, pathSummary } from "../momentLabels";
-import { BoardCardFrame } from "./BoardCardFrame";
-import { AskTheBuddy } from "../../buddy/components/AskTheBuddy";
-import type { BoardCard, PathToFirstContributionContent } from "../types";
-
-type PathCardProps = {
- content: PathToFirstContributionContent;
- card: Pick;
- onDismiss?: (cardId: string) => void;
- dismissing?: boolean;
-};
-
-/**
- * The path from joining to a first accepted piece of work.
- *
- * An unreached moment is a hollow dot and a dash — never a zero, because a milestone that has not
- * happened is not a milestone reached instantly.
- *
- * The stall reason is shown to the person in the stall, not only to their PM: a stall only somebody
- * else can see is a stall only somebody else can fix. It is framed as what is waiting, never as a
- * verdict on the hire.
- */
-export function PathToFirstContributionCard({
- content,
- card,
- onDismiss,
- dismissing,
-}: PathCardProps) {
- const { acceptedCount, autonomyReachedAt, stalledReason } = content;
-
- return (
-
-
- {content.moments.map((moment) => {
- const reached = moment.reachedAt !== null;
- return (
-
-
-
- Something is waiting: {stalledReason}. Ask your buddy about it — this is the kind of
- thing a person unblocks in a minute.
-
-
- )}
-
-
-
- {autonomyReachedAt && (
-
-
-
- You worked unsupervised here on {formatMoment(autonomyReachedAt)} — a{" "}
- {CONTRIBUTION_WORDING.noun} accepted with no rework and no one stepping in. Onboarding
- ended that day.
-
-
- )}
-
- );
-}
diff --git a/src/features/board/generation/pathToCards.ts b/src/features/board/generation/pathToCards.ts
deleted file mode 100644
index d58ada01e..000000000
--- a/src/features/board/generation/pathToCards.ts
+++ /dev/null
@@ -1,224 +0,0 @@
-import type { OnboardingPathEndpoint, OnboardingStepEndpoint } from "../../onboarding/types";
-import type { AuthoredCardRequest } from "../types";
-import type { BoardStage, DependencySource } from "../layout/boardStructure";
-
-/**
- * Turning the hire's personalised onboarding path into cards on their board.
- *
- * The path already exists and is already personalised: the AI service drafts it from the project's
- * own corpus, against blueprints a PM maintains, and it comes back as phases of steps of tasks. The
- * board did not know about any of it — which left the hire with a to-do list on one page and a
- * working surface on another, and nothing saying which of the two was the plan.
- *
- * So the board becomes where the path is *worked*, and the path stays where it is generated. One
- * step is one checklist card, its tasks are the lines on it, and the phase it came from is the area
- * the card sits in. Nothing here writes prose: every title and every line is text the path already
- * carried, so a card can be checked against the step it came from.
- *
- * TODO(backend): the cards this produces are posted through the ordinary authored-card endpoint, so
- * they come back owned by the hire rather than attributed to the buddy. That is right for editing —
- * they are the hire's list to tick, re-word and prune — and wrong for provenance, since a hire
- * cannot tell a card they wrote from one their path generated. A `POST /me/board/cards/from-path`
- * that mints them `AI`-owned with `placedAt` set, and still editable, is the shape this wants;
- * until then {@link CARD_SOURCE_MARKERS} is the only trace, and it is deliberately never shown.
- */
-
-/**
- * Steps whose contents are worth a card.
- *
- * A step already finished is a card that arrives ticked, which is a card that arrives as noise; a
- * skipped one was explicitly declined. Both stay on the path page, where their history belongs.
- */
-const CARD_WORTHY: readonly OnboardingStepEndpoint["status"][] = ["WAITING", "IN_PROGRESS"];
-
-/**
- * How a phase's position maps onto the board's two stages.
- *
- * A path has as many phases as it needs; the board has two coarse buckets, on purpose — see
- * `boardStructure.ts`. The first phase is what the hire does now, everything after it is later.
- * The finer order survives inside the areas, which keep the phases' own names and sequence, and in
- * the chains between the steps of a phase.
- */
-export function stageForPhase(index: number): BoardStage {
- return index === 0 ? "NOW" : "LATER";
-}
-
-/** One card to create, and where it belongs once it exists. */
-export type PlannedCard = {
- /** A key for this plan only — the real id is minted by the server on creation. */
- key: string;
- request: AuthoredCardRequest;
- /**
- * When this card is due.
- *
- * Per card rather than per area, because the two are different questions and only one of them is
- * a place. An area is where a card is filed — "Week one", "From your team" — and a stage is when
- * it comes up; a team's blueprints are one named set of cards that deliberately spans all three
- * stages. Carrying the stage on the area forced that set to be split into three areas with three
- * tab stops, which is a table of contents describing the sequencing rather than the board.
- */
- stage: BoardStage;
- /** The key of the card that must be finished first, or null for the first of a phase. */
- afterKey: string | null;
-};
-
-/** One named area of cards, as a plan would file them. */
-export type PlannedArea = {
- name: string;
- cards: PlannedCard[];
- /**
- * Who is claiming the chains in this area — the team, through a blueprint, or the buddy, through
- * a generated path.
- *
- * Carried on the area rather than on each card because it is a property of where the plan came
- * from, and one plan's areas never mix the two. It ends up on the board as the source of every
- * dependency the area writes, which is what stops a hire from clearing a rule their PM wrote and
- * what lets a buddy's suggestion say whose it was.
- */
- source: DependencySource;
-};
-
-export type CardPlan = {
- areas: PlannedArea[];
- /** How many cards the whole plan would create, for the confirmation the hire is shown. */
- cardCount: number;
-};
-
-/**
- * Where a generated checklist came from, written invisibly into its title.
- *
- * Two jobs, both of which want a `source` column and do not have one. A second generation run has
- * to recognise what the first one made so it does not create it twice; and the board's provenance
- * filter has to tell a card the *team* prescribed from one the hire's own path produced — "from
- * your team" is a real distinction to a new hire, and it is invisible in a checklist that looks
- * exactly like every other checklist.
- *
- * Zero-width characters at the front of the title: they never render, never affect a comparison the
- * hire can see, and survive the authored-card round trip because the title is stored verbatim.
- * {@link readableTitle} takes them off everywhere a person reads one.
- *
- * TODO(backend): this is a channel smuggled through a text field, and it should not outlive the
- * endpoint in the note above. A `source` on the card row — `HIRE` / `PATH` / `BLUEPRINT` — carries
- * the same fact without encoding it in something the hire can edit. When that lands, delete every
- * marker here and read the field.
- */
-export const CARD_SOURCE_MARKERS = {
- /** U+2063 invisible separator: a step of the hire's own personalised path. */
- PATH: "\u2063",
- /** U+2060 word joiner: a card blueprint the project's PM wrote for this role. */
- TEAM: "\u2060",
-} as const;
-
-export type GeneratedSource = keyof typeof CARD_SOURCE_MARKERS;
-
-/**
- * The title as stored: marked with where it came from.
- *
- * **Trimmed, because the server trims.** A blueprint whose title a PM typed with a trailing space,
- * or a path step the AI service handed over with a newline on the end, is stored without it — and a
- * later run that planned the untrimmed string would find no card by that name and write a second
- * one. Trimming here is the cheap half of that fix; {@link titleKey} is the half that holds when
- * something else in the round trip changes the string.
- */
-export function markTitle(source: GeneratedSource, title: string): string {
- return `${CARD_SOURCE_MARKERS[source]}${title.trim()}`;
-}
-
-/** Where a stored title came from, or null when the hire wrote it themselves. */
-export function sourceOfTitle(title: string | null): GeneratedSource | null {
- if (title === null) return null;
-
- return (
- (Object.keys(CARD_SOURCE_MARKERS) as GeneratedSource[]).find((source) =>
- title.startsWith(CARD_SOURCE_MARKERS[source]),
- ) ?? null
- );
-}
-
-/** Whether a title was written by a generation run rather than by the hire. */
-export function isGeneratedTitle(title: string | null): boolean {
- return sourceOfTitle(title) !== null;
-}
-
-/** The title as the hire reads it, with any marker taken off. */
-export function readableTitle(title: string): string {
- const source = sourceOfTitle(title);
-
- return source === null ? title : title.slice(CARD_SOURCE_MARKERS[source].length);
-}
-
-/**
- * What makes two cards the same card, for the purpose of not writing one twice.
- *
- * A generation run skips anything already on the board, and it can only recognise its own work by
- * the title — so the comparison has to survive everything that happens to a title between being
- * planned and being read back. It is trimmed and its inner runs of whitespace are collapsed,
- * because the server stores a trimmed string and nobody can see the difference between one space
- * and two. It is lowercased, because a card that differs from another only in capitalisation is a
- * duplicate to the person reading the board, whatever a string comparison thinks.
- *
- * **The marker comes off.** A step of the hire's path and a blueprint their PM wrote can name the
- * same piece of work, and the board would carry both as two cards with one visible title and no way
- * to tell them apart. Keyed on what the hire reads, the second one is recognised as already there —
- * and since blueprints are planned first, the version that survives is the one a person wrote.
- */
-export function titleKey(title: string | null): string {
- if (title === null) return "";
-
- return readableTitle(title).trim().replace(/\s+/g, " ").toLowerCase();
-}
-
-/**
- * The cards a path would put on the board, in the order they should be worked.
- *
- * **Steps inside a phase are chained; phases are not.** The path gives its steps an explicit
- * position, so within a phase "this before that" is something the path actually claims and the
- * board can honour. Across phases it is the stage that carries the order — chaining there as well
- * would leave a hire with exactly one card they are allowed to open out of forty, which is a
- * different kind of unusable from the one this is fixing.
- *
- * A step with no tasks still becomes a card, its expected outcomes as the lines: a step the path
- * did not break down is still a thing to do, and an empty card at least says what it is for.
- */
-export function planCardsFromPath(path: OnboardingPathEndpoint): CardPlan {
- const areas: PlannedArea[] = [];
-
- const phases = [...path.phases].sort((a, b) => a.position - b.position);
- phases.forEach((phase, phaseIndex) => {
- const steps = [...phase.steps]
- .sort((a, b) => a.position - b.position)
- .filter((step) => CARD_WORTHY.includes(step.status));
-
- const cards: PlannedCard[] = steps.map((step, stepIndex) => ({
- key: step.id,
- request: {
- kind: "CHECKLIST",
- title: markTitle("PATH", step.title),
- items: linesFor(step).map((text) => ({ text, done: false })),
- },
- stage: stageForPhase(phaseIndex),
- afterKey: stepIndex === 0 ? null : steps[stepIndex - 1].id,
- }));
-
- if (cards.length > 0) areas.push({ name: phase.title, cards, source: "BUDDY" });
- });
-
- return { areas, cardCount: areas.reduce((total, area) => total + area.cards.length, 0) };
-}
-
-/**
- * What goes on the checklist for one step.
- *
- * Tasks first, because they are the step broken into things somebody does. Failing that, the
- * expected outcomes — phrased as results rather than actions, but a hire ticking off "the project
- * builds locally" is still ticking off something true. Failing both, one line naming the step, so
- * the card is never a title over an empty box.
- */
-function linesFor(step: OnboardingStepEndpoint): string[] {
- if (step.tasks.length > 0) {
- return [...step.tasks].sort((a, b) => a.position - b.position).map((task) => task.title);
- }
- if (step.expectedOutcomes.length > 0) return step.expectedOutcomes;
-
- return [step.title];
-}
diff --git a/src/features/board/hooks/useBoardStructure.ts b/src/features/board/hooks/useBoardStructure.ts
index 1dce818a3..4b7d2b482 100644
--- a/src/features/board/hooks/useBoardStructure.ts
+++ b/src/features/board/hooks/useBoardStructure.ts
@@ -15,7 +15,6 @@ import {
type BoardStage,
type BoardStructure,
type CardState,
- type DependencySource,
} from "../layout/boardStructure";
export type UseBoardStructureResult = {
@@ -36,18 +35,6 @@ export type UseBoardStructureResult = {
* there, so the two never drift into disagreeing.
*/
setPredecessor: (cardId: string, blockerId: string | null) => void;
- /**
- * Applies a whole generated plan at once: every card's stage, and every link in every chain.
- *
- * One write rather than a call per card, and not for tidiness. Every other function here derives
- * the next structure from the one it closed over, so calling them in a loop would have each
- * iteration overwrite the last and leave only the final card sequenced — the kind of bug that
- * looks like "the generator only did the last phase" and is nothing of the sort.
- */
- applyPlan: (
- stages: Record,
- chain: Record,
- ) => void;
};
/**
@@ -90,16 +77,11 @@ export function useBoardStructure(boardId: string, cards: BoardCard[]): UseBoard
* Pruned on every write rather than on load: a card dismissed in this session should stop
* blocking things immediately, and storage should not accumulate rows for cards long gone.
*
- * `alsoKnown` is what keeps that from eating a freshly generated plan. Cards created a moment ago
- * are not in `cards` until the board is re-read, so pruning against `cards` alone would drop
- * every stage and every chain the generator just wrote — the whole plan, silently, between the
- * write and the reload.
- *
* Plain functions rather than `useCallback`: this project compiles with the React Compiler, which
* memoizes them itself and rejects hand-written dependency lists it cannot verify.
*/
- function save(next: BoardStructure, alsoKnown: readonly string[] = []) {
- const known = new Set([...cards.map((card) => card.id), ...alsoKnown]);
+ function save(next: BoardStructure) {
+ const known = new Set(cards.map((card) => card.id));
const pruned = pruneStructure(next, known);
setStructure(pruned);
writeBoardStructure(boardId, pruned);
@@ -121,15 +103,5 @@ export function useBoardStructure(boardId: string, cards: BoardCard[]): UseBoard
save(blockerId ? setDependency(cleared, cardId, blockerId, true, "HIRE") : cleared);
},
- applyPlan: (stages, chain) => {
- let next = structure;
- for (const [cardId, stage] of Object.entries(stages)) {
- next = setCardStage(next, cardId, stage);
- }
- for (const [cardId, predecessor] of Object.entries(chain)) {
- next = setDependency(next, cardId, predecessor.id, true, predecessor.source);
- }
- save(next, Object.keys(stages));
- },
};
}
diff --git a/src/features/board/hooks/useGeneratedPathCards.ts b/src/features/board/hooks/useGeneratedPathCards.ts
deleted file mode 100644
index 08f7ea34f..000000000
--- a/src/features/board/hooks/useGeneratedPathCards.ts
+++ /dev/null
@@ -1,210 +0,0 @@
-import { useState } from "react";
-import { ApiError } from "../../../services/apiClient";
-import { boardService } from "../../../services/boardService";
-import { onboardingService } from "../../../services/onboardingService";
-import { cardBlueprintService } from "../../card-blueprints/cardBlueprintService";
-import { blueprintsForRoles } from "../../card-blueprints/types";
-import {
- markTitle,
- planCardsFromPath,
- titleKey,
- type PlannedArea,
-} from "../generation/pathToCards";
-import { type BoardStage, type DependencySource } from "../layout/boardStructure";
-
-/** Why a generation run produced nothing, in words a page can put in a toast. */
-export type GenerationRefusal =
- /** Nothing to build from: no personalised path, and no blueprints for this hire's roles. */
- | "NOTHING_TO_BUILD"
- /** Everything that could be built is already a card, or already finished. */
- | "NOTHING_NEW"
- /** A card could not be written. */
- | "FAILED";
-
-/** What a successful run put on the board, for the page to file into areas and stages. */
-export type GenerationResult = {
- cardCount: number;
- /** The areas to create, in order. An area is a *place*; when a card is due is a separate answer. */
- areas: { name: string; cardIds: string[] }[];
- /** Card id to the stage it belongs in. */
- stages: Record;
- /** Card id to the id of the card that must be finished first. */
- /** Each chained card's predecessor, and who is claiming the link. */
- chain: Record;
-};
-
-type UseGeneratedPathCardsResult = {
- /**
- * Builds this hire's onboarding into cards on their board.
- *
- * Resolves with what was created, or with the reason nothing was. Never throws: a page that has
- * to try/catch around a button is a page that will forget to.
- *
- * @param projectId The project whose board is being filled.
- * @param roleIds The hire's roles on that project, which decide the blueprints that apply.
- * @param existingTitles Checklist titles already on the board, exactly as they are stored — the
- * run compares them itself, by {@link titleKey}, so a caller never has to know how a title is
- * marked or how the server trimmed it.
- */
- generate: (
- projectId: string,
- roleIds: readonly string[],
- existingTitles: Iterable,
- ) => Promise;
- generating: boolean;
-};
-
-/**
- * Fills the hire's board from the two things that know what they should be doing.
- *
- * **The team's blueprints**, which a PM wrote once for everybody of a given role, and **the
- * personalised path**, which the AI service drafts for this hire from the project's own corpus.
- * Both existed already and neither reached the board: blueprints had no consumer, and the path
- * lived on a page a hire visited once and never worked from. A checklist they tick on the surface
- * they already use is the same plan, in the place it gets used.
- *
- * Blueprints come first. A blueprint is what a person decided every hire of this role needs; the
- * path is what the corpus suggests. When the two disagree about what comes first, the person wins.
- *
- * **Created one at a time, in order.** The board's own order is creation order, so posting in the
- * order the plan puts them in means the board reads as the plan reads before anybody arranges
- * anything. It is a handful of small writes rather than one batch because there is no batch
- * endpoint; the loop stops at the first failure and reports what it managed, so a hire whose
- * network dropped halfway gets the cards that landed rather than a board in an unknown state.
- *
- * A card whose title is already on the board is skipped, which is what makes running this twice
- * safe — a hire who generates, dismisses two cards and generates again gets those two back and
- * nothing else duplicated.
- *
- * **Already-there is judged by {@link titleKey}, not by the stored string.** The comparison is the
- * only thing standing between a hire and two of every card, and an exact match is too brittle to be
- * it: the server trims what it stores, a PM's blueprint title may have been typed with a trailing
- * space, and the path and the blueprints can name the same work under different markers. Every one
- * of those reads as "no card by that name" and writes a second copy — which is what a hire sees as
- * their old card and a new one saying the same thing. Nothing about that is helped by the areas
- * they are filed in, so a hire who has since dissolved an area sees the two copies side by side.
- */
-export function useGeneratedPathCards(): UseGeneratedPathCardsResult {
- const [generating, setGenerating] = useState(false);
-
- async function generate(
- projectId: string,
- roleIds: readonly string[],
- existingTitles: Iterable,
- ): Promise {
- setGenerating(true);
- try {
- // Grows as the run goes: two blueprints named the same thing are the same card as surely as
- // one blueprint and a card already on the board, and only one of them should be written.
- const present = new Set([...existingTitles].map(titleKey));
- const plan = [...(await blueprintAreas(projectId, roleIds)), ...(await pathAreas())];
- if (plan.length === 0) return "NOTHING_TO_BUILD";
-
- const areas: GenerationResult["areas"] = [];
- const stages: Record = {};
- const chain: GenerationResult["chain"] = {};
- // Kept across every area rather than per area, because a blueprint may say it comes after one
- // in a different stage — and a chain that silently dropped at an area boundary would look
- // exactly like the PM never having set it.
- const mintedByKey = new Map();
- let created = 0;
-
- for (const area of plan) {
- const cardIds: string[] = [];
-
- for (const plannedCard of area.cards) {
- // Only a titled checklist can be recognised again; anything else is written as planned.
- const key =
- plannedCard.request.kind === "CHECKLIST"
- ? titleKey(plannedCard.request.title ?? null)
- : "";
- if (key !== "") {
- if (present.has(key)) continue;
- present.add(key);
- }
-
- const card = await boardService.addCard(projectId, plannedCard.request);
- mintedByKey.set(plannedCard.key, card.id);
- cardIds.push(card.id);
- stages[card.id] = plannedCard.stage;
- created += 1;
-
- // A predecessor that was skipped as already-present has no minted id, so this card simply
- // waits on nothing rather than on a card that was never created.
- const afterId = plannedCard.afterKey ? mintedByKey.get(plannedCard.afterKey) : undefined;
- if (afterId) chain[card.id] = { id: afterId, source: area.source };
- }
-
- if (cardIds.length > 0) areas.push({ name: area.name, cardIds });
- }
-
- if (created === 0) return "NOTHING_NEW";
-
- return { cardCount: created, areas, stages, chain };
- } catch {
- return "FAILED";
- } finally {
- setGenerating(false);
- }
- }
-
- return { generate, generating };
-}
-
-/**
- * The hire's personalised path as areas of cards, or none when they have no path yet.
- *
- * A missing path is an ordinary state — a hire who has never generated one on the onboarding page
- * simply has none — so the 404 is absorbed here rather than failing the whole run. Blueprints can
- * still fill a board on their own, and often should: they are the half that does not need the hire
- * to have done anything first.
- */
-async function pathAreas(): Promise {
- try {
- return planCardsFromPath(await onboardingService.fetchPath()).areas;
- } catch (error) {
- if (error instanceof ApiError && error.status === 404) return [];
- throw error;
- }
-}
-
-/**
- * The project's card blueprints for this hire's roles, as one area of cards.
- *
- * **One area, whatever stages the blueprints span.** This used to be one area per stage, because a
- * plan's stage lived on the area — which meant a team that had sequenced its cards across the
- * stages arrived as several areas called "From your team — Now", "— Later" and so on, one tab stop
- * each in the board's table of contents for one set of cards somebody wrote in one sitting. The
- * stage is on the card now (see {@link PlannedArea}), so what the team prescribed is one place on
- * the board and the stages fold inside it like they do everywhere else.
- *
- * Ordered by the PM's own ordering rather than by stage: `blueprintsForRoles` sorts by position,
- * which is the order the editor's list is in and the order the cards are created in.
- *
- * A project with no blueprints yields nothing, which is the ordinary state on an installation where
- * nobody has written any — not an error, and not an empty area.
- */
-async function blueprintAreas(
- projectId: string,
- roleIds: readonly string[],
-): Promise {
- const blueprints = blueprintsForRoles(await cardBlueprintService.list(projectId), roleIds);
- if (blueprints.length === 0) return [];
-
- return [
- {
- name: "From your team",
- source: "TEAM",
- cards: blueprints.map((blueprint) => ({
- key: blueprint.id,
- request: {
- kind: "CHECKLIST" as const,
- title: markTitle("TEAM", blueprint.title),
- items: blueprint.items.map((text) => ({ text, done: false })),
- },
- stage: blueprint.stage,
- afterKey: blueprint.afterId,
- })),
- },
- ];
-}
diff --git a/src/features/board/layout/boardFilters.ts b/src/features/board/layout/boardFilters.ts
index 4a9caba78..db469a06e 100644
--- a/src/features/board/layout/boardFilters.ts
+++ b/src/features/board/layout/boardFilters.ts
@@ -1,21 +1,16 @@
import type { LucideIcon } from "lucide-react";
import { Bot, User } from "lucide-react";
-import { sourceOfTitle } from "../generation/pathToCards";
import type { BoardCard } from "../types";
/**
* Which cards to show, by *where they came from*.
*
- * There used to be a fourth, "team", for the cards the project's blueprints put here. It is gone,
- * and the section bar is why: the generator files those cards into an area called "From your team",
- * so the filter and the bar were two controls cutting the same set — under the same words, from two
- * different facts. The bar's is the better of the two. It cuts on where a card actually *is*, which
- * is a thing a person can see and change; the filter's cut on an invisible marker in the card's
- * title (see `generation/pathToCards.ts`, and the TODO to take it away), so the two disagreed the
- * moment somebody moved a card out of the area.
+ * There used to be a third, "team", for the cards a PM's card blueprints put here. Card blueprints
+ * and the generator that wrote those cards are gone -- onboarding is the path on its own page -- so a
+ * checklist a hire has is theirs, whoever's idea it first was.
*
- * What is left has no equivalent among the sections: nobody files cards into an area by who wrote
+ * Neither cut has an equivalent among the sections: nobody files cards into an area by who wrote
* them.
*/
export type BoardFilter = "all" | "buddy" | "mine";
@@ -48,23 +43,11 @@ export const FILTER_OPTIONS: {
{ value: "mine", label: "Yours", icon: User },
];
-/**
- * Whether a card came from the project's blueprints rather than from the hire or their buddy.
- *
- * Read off the invisible marker its title carries — see `generation/pathToCards.ts`, which explains
- * why provenance is smuggled through a text field and what should replace it.
- */
-export function isFromTeam(card: BoardCard): boolean {
- return card.content.kind === "CHECKLIST" && sourceOfTitle(card.content.title) === "TEAM";
-}
-
export function matchesFilter(card: BoardCard, filter: BoardFilter): boolean {
if (filter === "all") return true;
if (filter === "buddy") return card.owner === "AI";
- // "Yours" still means yours: a blueprint card is stored as the hire's so they can edit it, but
- // the team wrote it. Those are reached through their area, which is where they were put.
- return card.owner === "HIRE" && !isFromTeam(card);
+ return card.owner === "HIRE";
}
/** The label of the cut currently in force, or null when nothing is cut away. */
diff --git a/src/features/board/layout/boardGroups.ts b/src/features/board/layout/boardGroups.ts
index e08947480..17bf110ac 100644
--- a/src/features/board/layout/boardGroups.ts
+++ b/src/features/board/layout/boardGroups.ts
@@ -79,7 +79,7 @@ export function readBoardGroups(boardId: string): BoardGroup[] {
}
}
-/** What the generator calls the area holding a team's card blueprints. */
+/** What the since-removed generator called the area holding a team's card blueprints. */
const TEAM_AREA = "From your team";
/**
diff --git a/src/features/board/layout/boardStructure.ts b/src/features/board/layout/boardStructure.ts
index d3f7dfe4f..40de638be 100644
--- a/src/features/board/layout/boardStructure.ts
+++ b/src/features/board/layout/boardStructure.ts
@@ -91,6 +91,10 @@ export function stageOrder(stage: BoardStage): number {
* - `BUDDY` — from a generated path. Named on the card so it does not look like the hire's own
* doing, but still theirs to clear: the buddy is an assistant, not an authority.
* - `HIRE` — theirs, and the only kind their own controls write.
+ *
+ * Nothing writes `TEAM` or `BUDDY` any more: card blueprints and the generator that copied the path
+ * onto the board were retired when the onboarding path became the one plan (#311). Boards arranged
+ * before then still hold such edges, and they keep their meaning.
*/
export type DependencySource = "TEAM" | "BUDDY" | "HIRE";
@@ -251,7 +255,6 @@ export function isSelfReporting(card: BoardCard): boolean {
switch (card.content.kind) {
case "CHECKLIST":
case "ARRIVAL_STEPS":
- case "PATH_TO_FIRST_CONTRIBUTION":
return true;
case "PATH_STEP":
// A degraded card (`reason` set) or a live step that simply has no tasks would otherwise be
@@ -283,10 +286,6 @@ export function cardProgress(card: BoardCard): { done: number; total: number } |
const total = content.steps.length;
return { done: total - content.outstandingCount, total };
}
- case "PATH_TO_FIRST_CONTRIBUTION": {
- const total = content.moments.length;
- return { done: content.moments.filter((moment) => moment.reachedAt !== null).length, total };
- }
case "PATH_STEP": {
const total = content.tasks.length;
return { done: content.tasks.filter((task) => task.finished).length, total };
diff --git a/src/features/board/layout/cardAccents.ts b/src/features/board/layout/cardAccents.ts
index a4c4bf573..fbc903f34 100644
--- a/src/features/board/layout/cardAccents.ts
+++ b/src/features/board/layout/cardAccents.ts
@@ -89,8 +89,7 @@ const ORANGE: CardAccent = {
};
const ACCENTS: Record = {
- // What the board is steering by: where you are going, and what you are on right now.
- PATH_TO_FIRST_CONTRIBUTION: BRAND,
+ // What the board is steering by: what you are on right now.
CURRENT_TASK: BRAND,
// Things that come from somewhere outside the board — the joining process, and the repository.
diff --git a/src/features/board/layout/cardIcons.ts b/src/features/board/layout/cardIcons.ts
index 0e9eb9219..b7e81caef 100644
--- a/src/features/board/layout/cardIcons.ts
+++ b/src/features/board/layout/cardIcons.ts
@@ -9,7 +9,6 @@ import {
Network,
PenLine,
PlaneLanding,
- Route,
Sparkles,
Target,
type LucideIcon,
@@ -30,7 +29,6 @@ import type { BoardCardKind } from "../types";
* a confident wrong one reads as a different card.
*/
const ICONS: Record = {
- PATH_TO_FIRST_CONTRIBUTION: Route,
CURRENT_TASK: Target,
DIAGRAM: Network,
ARRIVAL_STEPS: PlaneLanding,
diff --git a/src/features/board/layout/cardNames.ts b/src/features/board/layout/cardNames.ts
index 74e724a13..7b7782195 100644
--- a/src/features/board/layout/cardNames.ts
+++ b/src/features/board/layout/cardNames.ts
@@ -1,6 +1,18 @@
-import { readableTitle } from "../generation/pathToCards";
import { PATH_STEP_FALLBACK_TITLE, type BoardCard } from "../types";
+/**
+ * The invisible marks the retired "Build my path" generator put at the front of a checklist title:
+ * U+2063 for a step of the onboarding path, U+2060 for a card blueprint. The generator is gone --
+ * onboarding is the path on its own page -- but cards it made are still on boards, so the marks are
+ * still taken off wherever a title is read.
+ */
+const LEGACY_TITLE_MARKS = /^[\u2060\u2063]/;
+
+/** A checklist title as the hire reads it. */
+export function readableTitle(title: string): string {
+ return title.replace(LEGACY_TITLE_MARKS, "");
+}
+
/**
* What to call a card when it is being talked about from somewhere else on the board.
*
@@ -22,8 +34,6 @@ export function cardName(card: BoardCard): string {
return content.subject;
case "ARRIVAL_STEPS":
return "Your arrival steps";
- case "PATH_TO_FIRST_CONTRIBUTION":
- return "Your path to a first contribution";
case "OPEN_PULL_REQUESTS":
return "Your open pull requests";
case "CURRENT_TASK":
diff --git a/src/features/board/momentLabels.ts b/src/features/board/momentLabels.ts
deleted file mode 100644
index 24ac35dae..000000000
--- a/src/features/board/momentLabels.ts
+++ /dev/null
@@ -1,37 +0,0 @@
-import { CONTRIBUTION_WORDING } from "../../config/contributionWording";
-import type { BoardMomentKey } from "./types";
-
-/**
- * What each moment on the path is called.
- *
- * The two middle moments are built from the track's noun rather than named after git, because the
- * moments themselves are not about git — somebody whose work is a facilitated ceremony still
- * submits it and still waits for somebody to respond.
- *
- * Lives here rather than beside one of its two renderers: the path is drawn as a rail in the board
- * header and as a card in the grid, and the same moment must not be able to answer to two names
- * depending on which one you are looking at.
- */
-export function momentLabel(key: BoardMomentKey): string {
- switch (key) {
- case "JOINED":
- return "Joined";
- case "TASK_CLAIMED":
- return "Task claimed";
- case "WORK_SUBMITTED":
- return `First ${CONTRIBUTION_WORDING.noun} submitted`;
- case "FIRST_RESPONSE":
- return "Somebody responded";
- case "WORK_ACCEPTED":
- return `First ${CONTRIBUTION_WORDING.noun} ${CONTRIBUTION_WORDING.verbPast}`;
- }
-}
-
-/** The one-line summary the path carries above it, in both of its forms. */
-export function pathSummary(acceptedCount: number): string {
- if (acceptedCount === 0) {
- return `Nothing ${CONTRIBUTION_WORDING.verbPast} yet — that's normal early on`;
- }
- const noun = acceptedCount === 1 ? CONTRIBUTION_WORDING.noun : CONTRIBUTION_WORDING.nounPlural;
- return `${acceptedCount} ${noun} ${CONTRIBUTION_WORDING.verbPast}`;
-}
diff --git a/src/features/board/types.ts b/src/features/board/types.ts
index 6e3e33312..00cd50721 100644
--- a/src/features/board/types.ts
+++ b/src/features/board/types.ts
@@ -11,7 +11,6 @@ import type { TaskType } from "../starter-work/types";
/** Every card kind the board understands. Closed set — see the module comment. */
export type BoardCardKind =
- | "PATH_TO_FIRST_CONTRIBUTION"
| "ARRIVAL_STEPS"
| "OPEN_PULL_REQUESTS"
| "CURRENT_TASK"
@@ -84,32 +83,6 @@ export type BoardUndoNotice = {
forReplacedAt: string | null;
};
-/** The moments a path card reports, in the order they normally happen. */
-export type BoardMomentKey =
- "JOINED" | "TASK_CLAIMED" | "WORK_SUBMITTED" | "FIRST_RESPONSE" | "WORK_ACCEPTED";
-
-/** One moment, and whether it has happened. `null` is "not yet", and renders as a dash, never a zero. */
-export type BoardMoment = {
- key: BoardMomentKey;
- reachedAt: string | null;
-};
-
-/**
- * The path from joining to a first accepted piece of work.
- *
- * Composed from contributions, not pull requests, so it says something true whatever produces
- * this hire's work.
- */
-export type PathToFirstContributionContent = {
- kind: "PATH_TO_FIRST_CONTRIBUTION";
- moments: BoardMoment[];
- acceptedCount: number;
- /** When onboarding ended, dated. Null while it is still going. */
- autonomyReachedAt: string | null;
- /** Why the hire currently reads as stalled, in plain words; null when they do not. */
- stalledReason: string | null;
-};
-
/** One open pull request. `waitingHours` is null once somebody has responded — the clock stopped. */
export type BoardPullRequest = {
artifactId: string;
@@ -164,8 +137,6 @@ export type CurrentTaskContent = {
title: string | null;
summary: string | null;
url: string | null;
- /** True when the hire claimed this as their goal, false when it is the Task 0 they were handed. */
- chosen: boolean;
/**
* True once the issue behind this task is closed where it lives. False when there is no task at
* all, which says nothing about anything.
@@ -394,7 +365,6 @@ export type ChecklistContent = {
/** The rendered content of one card, discriminated by `kind`. */
export type BoardCardContent =
- | PathToFirstContributionContent
| ArrivalStepsContent
| OpenPullRequestsContent
| CurrentTaskContent
diff --git a/src/features/buddy/aiBuddyBus.ts b/src/features/buddy/aiBuddyBus.ts
index 791dc6e86..36e64bbed 100644
--- a/src/features/buddy/aiBuddyBus.ts
+++ b/src/features/buddy/aiBuddyBus.ts
@@ -54,3 +54,28 @@ export function onBuddyPageReady(handler: () => void): () => void {
window.addEventListener(BUDDY_PAGE_READY_EVENT, handler);
return () => window.removeEventListener(BUDDY_PAGE_READY_EVENT, handler);
}
+
+const BUDDY_PATH_CHANGED_EVENT = "sprintstart:buddy-path-changed";
+
+/**
+ * Announced after the hire confirms a buddy action that changed their onboarding path.
+ *
+ * The buddy lives in a dock over whatever page the hire is on, and the path page is the page they
+ * are most likely to be on while talking about their path. Without this, confirming "mark this step
+ * as done" left a page behind the dock still showing it open — the hire's own click looking like it
+ * had done nothing.
+ *
+ * A signal rather than shared state, for the same reason `openAiBuddy` is one: the dock would
+ * otherwise have to know about the onboarding page's data layer, and every other surface that grows
+ * an interest in the path would have to be wired through it too. Whoever is showing a path listens;
+ * nobody has to.
+ */
+export function announceBuddyPathChanged(): void {
+ window.dispatchEvent(new Event(BUDDY_PATH_CHANGED_EVENT));
+}
+
+/** Subscribes to path changes the buddy made. Returns an unsubscribe function. */
+export function onBuddyPathChanged(handler: () => void): () => void {
+ window.addEventListener(BUDDY_PATH_CHANGED_EVENT, handler);
+ return () => window.removeEventListener(BUDDY_PATH_CHANGED_EVENT, handler);
+}
diff --git a/src/features/buddy/components/BuddyActionProposals.tsx b/src/features/buddy/components/BuddyActionProposals.tsx
index 9a7c6c41e..9c28b4ecc 100644
--- a/src/features/buddy/components/BuddyActionProposals.tsx
+++ b/src/features/buddy/components/BuddyActionProposals.tsx
@@ -2,6 +2,7 @@ import { Check, Info, Loader2, RotateCcw, TriangleAlert, Users, X } from "lucide
import type { ProposedAction, ProposalRisk } from "../types";
import {
BUDDY_ACTION_AMEND_CHECKLIST,
+ BUDDY_ACTION_FLAG_TO_PM,
BUDDY_ACTION_EDIT_CHECKLIST,
BUDDY_ACTION_OPEN_ORIENTATION,
BUDDY_ACTION_REWORD_CHECKLIST,
@@ -262,6 +263,20 @@ export function BuddyActionProposals({
)}
+ {/* Anything that leaves the product in the hire's name shows its words before the
+ click -- the label only names the kind of thing it is. A skip request carries the
+ reason; a flag to the PM carries the question the buddy composed, which is the whole
+ of what that person will read. */}
+ {!isStored && action.reason && (
+
Couldn't reach the server — try again.
diff --git a/src/features/buddy/components/BuddyComposer.tsx b/src/features/buddy/components/BuddyComposer.tsx
index 69e386b69..322705a73 100644
--- a/src/features/buddy/components/BuddyComposer.tsx
+++ b/src/features/buddy/components/BuddyComposer.tsx
@@ -84,6 +84,28 @@ export function BuddyComposer({
field.setSelectionRange(field.value.length, field.value.length);
}, [focusOnMount]);
+ /**
+ * The last value the hire typed here, so a draft written *from outside* can be told apart.
+ *
+ * A suggestion chip, or "Ask your buddy about this step" while the dock is already open, sets the
+ * draft without touching the box — and focus stayed on whatever was clicked. Pressing Enter then
+ * re-clicked the chip instead of sending, so a filled-in question looked unsendable. Any draft
+ * that did not come from typing takes the caret, behind the text, which is where it has to be for
+ * Enter to send it and for typing to add to it.
+ */
+ const typedRef = useRef(draft);
+
+ useEffect(() => {
+ if (draft === typedRef.current) return;
+ typedRef.current = draft;
+ // Cleared after a send: nothing to hand over, and the caret is not wanted back mid-turn.
+ if (!draft) return;
+ const field = fieldRef.current;
+ if (!field) return;
+ field.focus();
+ field.setSelectionRange(field.value.length, field.value.length);
+ }, [draft]);
+
// The mirror of the blur in `submit`: once the buddy has stopped writing, the caret comes
// back so a follow-up question does not need a click first. Skipped when somebody else holds
// focus, so this never pulls the caret out of a field the hire moved to in the meantime.
@@ -147,7 +169,10 @@ export function BuddyComposer({
value={draft}
rows={1}
placeholder={placeholder}
- onChange={(event) => setDraft(event.target.value)}
+ onChange={(event) => {
+ typedRef.current = event.target.value;
+ setDraft(event.target.value);
+ }}
onKeyDown={handleKeyDown}
className="min-w-0 flex-1 resize-none overflow-y-auto bg-transparent px-2 py-1.5 text-sm text-app-text outline-none placeholder:text-app-text-disabled"
/>
diff --git a/src/features/buddy/components/BuddyMarkdown.tsx b/src/features/buddy/components/BuddyMarkdown.tsx
index ca7256c01..95f2e4d1d 100644
--- a/src/features/buddy/components/BuddyMarkdown.tsx
+++ b/src/features/buddy/components/BuddyMarkdown.tsx
@@ -1,7 +1,23 @@
import { memo } from "react";
import ReactMarkdown from "react-markdown";
+import { Link } from "react-router-dom";
import remarkGfm from "remark-gfm";
+/**
+ * Whether a link the buddy wrote points inside the app.
+ *
+ * The mentor is given the paths of the things it talks about — a step, a question, a phase — so that
+ * "want to take the check?" can arrive as something clickable. Those are app paths, and opening one
+ * in a new tab would reload the whole SPA and lose the conversation the hire was having.
+ *
+ * Root-relative only, and deliberately: a protocol-relative `//evil.example` is also "relative" to a
+ * careless check, and the model's output is not a place to be careless. `/\evil.example` is the same
+ * thing in disguise -- browsers read the backslash as a slash.
+ */
+function isInAppPath(href: string | undefined): href is string {
+ return href !== undefined && href.startsWith("/") && href[1] !== "/" && href[1] !== "\\";
+}
+
/**
* Renders the buddy's reply as Markdown (GitHub-flavoured), so lists, bold, headings, links,
* code and tables come through instead of raw asterisks and backticks. Shared by both buddy
@@ -34,11 +50,17 @@ function BuddyMarkdownImpl({ content }: { content: string }) {
(
-
- {children}
-
- ),
+ // An app path navigates in place; anything else is still somebody else's site in a new
+ // tab. The dock stays mounted across a route change, so a hire who follows a link into
+ // their path keeps the conversation that sent them there.
+ a: ({ children, href }) =>
+ isInAppPath(href) ? (
+ {children}
+ ) : (
+
+ {children}
+
+ ),
table: ({ children }) => (
diff --git a/src/features/buddy/hooks/useBuddyConversation.ts b/src/features/buddy/hooks/useBuddyConversation.ts
index a38440876..2a32669f5 100644
--- a/src/features/buddy/hooks/useBuddyConversation.ts
+++ b/src/features/buddy/hooks/useBuddyConversation.ts
@@ -9,6 +9,8 @@ import {
streamMessage,
type BuddyOpeningAction,
} from "../../../services/buddyService";
+import { announceBuddyPathChanged } from "../aiBuddyBus";
+import { BUDDY_PATH_ACTIONS } from "../types";
import { useAuth } from "../../../context/useAuth";
import { useInvalidateBoard } from "../../board/hooks/useInvalidateBoard";
import {
@@ -677,6 +679,16 @@ export function useBuddyConversation(
noteText: proposal.noteText,
lineBefore: proposal.lineBefore,
lineAfter: proposal.lineAfter,
+ stepId: proposal.stepId,
+ questionId: proposal.questionId,
+ phaseId: proposal.phaseId,
+ onboardingTaskId: proposal.onboardingTaskId,
+ answer: proposal.answer,
+ optionIds: proposal.optionIds,
+ description: proposal.description,
+ reason: proposal.reason,
+ waitsOnIds: proposal.waitsOnIds,
+ unlocksIds: proposal.unlocksIds,
linkUrl: proposal.linkUrl,
linkLabel: proposal.linkLabel,
cardIds: proposal.cardIds,
@@ -825,6 +837,16 @@ export function useBuddyConversation(
noteText: action.noteText,
lineBefore: action.lineBefore,
lineAfter: action.lineAfter,
+ stepId: action.stepId,
+ questionId: action.questionId,
+ phaseId: action.phaseId,
+ onboardingTaskId: action.onboardingTaskId,
+ answer: action.answer,
+ optionIds: action.optionIds,
+ description: action.description,
+ reason: action.reason,
+ waitsOnIds: action.waitsOnIds,
+ unlocksIds: action.unlocksIds,
linkUrl: action.linkUrl,
linkLabel: action.linkLabel,
cardIds: action.cardIds,
@@ -834,6 +856,11 @@ export function useBuddyConversation(
ok: result.ok,
outcome: result.message,
});
+ // A path action just moved something on a page that may be open behind this dock. Told
+ // rather than polled, and only on success: a refused confirm changed nothing to refresh.
+ if (result.ok && "action" in action && BUDDY_PATH_ACTIONS.includes(action.action)) {
+ announceBuddyPathChanged();
+ }
// `board.all()`, not a project key: the backend re-resolves the project server-side
// (the caller's single onboarding project) and never tells the client which board —
diff --git a/src/features/buddy/hooks/useBuddyPathSync.ts b/src/features/buddy/hooks/useBuddyPathSync.ts
new file mode 100644
index 000000000..194bfbe6d
--- /dev/null
+++ b/src/features/buddy/hooks/useBuddyPathSync.ts
@@ -0,0 +1,25 @@
+import { useQueryClient } from "@tanstack/react-query";
+import { useEffect } from "react";
+import { queryKeys } from "../../../services/queryKeys";
+import { onBuddyPathChanged } from "../aiBuddyBus";
+
+/**
+ * Marks every cached read of the hire's path stale once the buddy changed it.
+ *
+ * The pages that fetch the path themselves listen for the signal on their own; this is for the
+ * ones that read it through the query cache -- the board, whose path-step cards and progress are
+ * part of the board query, and the dashboard's next-step card. Mounted once, at the top of the app,
+ * because the dock that confirms the change can sit over any of them.
+ */
+export function useBuddyPathSync(): void {
+ const queryClient = useQueryClient();
+
+ useEffect(
+ () =>
+ onBuddyPathChanged(() => {
+ void queryClient.invalidateQueries({ queryKey: queryKeys.onboarding.myStatuses() });
+ void queryClient.invalidateQueries({ queryKey: queryKeys.board.all() });
+ }),
+ [queryClient],
+ );
+}
diff --git a/src/features/buddy/types.ts b/src/features/buddy/types.ts
index 00a5f2bf6..4c9c8978a 100644
--- a/src/features/buddy/types.ts
+++ b/src/features/buddy/types.ts
@@ -29,6 +29,28 @@ export type ActionPatch = {
*/
export const BUDDY_ACTION_OPEN_ORIENTATION = "open_orientation";
+/**
+ * The backend's `flag_to_pm` action. Its `question` is the message that goes to the PM, so the
+ * confirm shows it: this one leaves the product and arrives in somebody's inbox in the hire's name.
+ */
+export const BUDDY_ACTION_FLAG_TO_PM = "flag_to_pm";
+
+/**
+ * The actions that change the hire's onboarding path.
+ *
+ * Listed once, here, because two surfaces need the same answer: confirming one of these has to tell
+ * whatever is showing a path that it is now stale (see `announceBuddyPathChanged`). Answering a
+ * question counts even when the answer was wrong — the attempt is recorded and the question's status
+ * moves either way.
+ */
+export const BUDDY_PATH_ACTIONS: readonly string[] = [
+ "complete_step",
+ "complete_task",
+ "answer_question",
+ "add_path_step",
+ "request_skip",
+];
+
/**
* The backend's `place_checklist` action: the mentor offering to keep a list it just wrote.
*
@@ -90,11 +112,14 @@ export const BUDDY_ACTION_REORDER_CARDS = "reorder_cards";
export type HireActionProposal = {
/** Local id for keying and targeting the confirm — the backend doesn't assign one. */
id: string;
- /** The action's tool name, sent back verbatim to confirm it (e.g. "claim_task_zero"). */
+ /** The action's tool name, sent back verbatim to confirm it (e.g. "claim_goal"). */
action: string;
- /** The button text ("Start Task 0"). */
+ /** The button text ("Work toward this task"). */
label: string;
- /** Carried through only for flag-to-PM: the question the buddy composed. */
+ /**
+ * Carried through only for flag-to-PM: the question the buddy composed, and the one that is
+ * actually sent. Shown under the button — see `BuddyActionProposals`.
+ */
question?: string;
/**
* The goal-claim confirm payload (`claim_goal`), echoed back verbatim so the action runs
@@ -128,6 +153,37 @@ export type HireActionProposal = {
*/
competencyKey?: string;
level?: string;
+ /**
+ * The path-action confirm payloads: which node of the hire's own onboarding path the action is
+ * aimed at, the answer `answer_question` would send, and a new step's description.
+ *
+ * Echoed back verbatim for the same reason as `githubLogin`: the hire reads the step, or their own
+ * answer, on the button before agreeing to it, so what gets written has to be what they were
+ * shown — never something the client derived afterwards.
+ */
+ stepId?: string;
+ questionId?: string;
+ phaseId?: string;
+ onboardingTaskId?: string;
+ answer?: string;
+ /**
+ * The options a multiple-choice `answer` stands for, as the backend resolved them when it
+ * proposed. Echoed back so the confirm can check the button still means what it says — the
+ * backend refuses it if the question changed in between.
+ */
+ optionIds?: string[];
+ description?: string;
+ /**
+ * The reason `request_skip` sends to the PM. Shown in full under the button, because it goes out
+ * in the hire's name and a label has no room for it.
+ */
+ reason?: string;
+ /**
+ * Where `add_path_step` puts the new step in its phase's graph — what it waits on, and what will
+ * wait on it. Echoed back verbatim and re-checked against the hire's own path on confirm.
+ */
+ waitsOnIds?: string[];
+ unlocksIds?: string[];
/**
* The `place_checklist` confirm payload: the list the buddy wrote and offered to keep.
*
@@ -293,7 +349,7 @@ export type BuddyStreamHandlers = {
/** Optional: only some turns run a tool, and the surface may not show which. */
onToolUse?: (tool: string) => void;
/**
- * The buddy has *proposed* an action the hire must confirm (e.g. "Start Task 0"). Nothing has
+ * The buddy has *proposed* an action the hire must confirm (e.g. "Work toward this task"). Nothing has
* changed yet — the surface renders a confirm affordance and only mutates when the hire clicks.
*/
onActionProposal?: (proposal: {
@@ -306,6 +362,16 @@ export type BuddyStreamHandlers = {
githubLogin?: string;
competencyKey?: string;
level?: string;
+ stepId?: string;
+ questionId?: string;
+ phaseId?: string;
+ onboardingTaskId?: string;
+ answer?: string;
+ optionIds?: string[];
+ description?: string;
+ reason?: string;
+ waitsOnIds?: string[];
+ unlocksIds?: string[];
checklistTitle?: string;
checklistItems?: string[];
cardId?: string;
diff --git a/src/features/card-blueprints/cardBlueprintService.ts b/src/features/card-blueprints/cardBlueprintService.ts
deleted file mode 100644
index 4200a335b..000000000
--- a/src/features/card-blueprints/cardBlueprintService.ts
+++ /dev/null
@@ -1,154 +0,0 @@
-import type { CardBlueprint, CardBlueprintDraft } from "./types";
-
-/**
- * Where a project's card blueprints are kept.
- *
- * Local storage, per project, and this one is a *placeholder* rather than a considered trade-off —
- * unlike the board's own local layers, which are one hire's preferences about one machine. These
- * are a team's decisions, authored by a PM and consumed by every hire on the project, so they
- * belong on the server and nowhere else. Kept here so the shape can be agreed and the screens
- * exercised before the endpoints exist.
- *
- * TODO(backend): `GET/POST/PUT/DELETE /api/v1/onboarding/projects/{projectId}/card-blueprints`,
- * authorised the way the other PM surfaces are (`@projectAuth.canAccessProject`). The functions
- * below are already async and already fail loudly, so the swap is one file. Until then, blueprints
- * a PM writes are visible only in the browser they were written in — which is fine for agreeing the
- * model and not fine for anything else.
- */
-const STORAGE_VERSION = 1;
-
-function storageKey(projectId: string): string {
- return `sprintstart:card-blueprints:${projectId}`;
-}
-
-type Stored = {
- version: number;
- blueprints: unknown;
-};
-
-/** One stored blueprint, with anything unrecognised dropped rather than trusted. */
-function toBlueprint(value: unknown): CardBlueprint | null {
- if (typeof value !== "object" || value === null) return null;
- const raw = value as Record;
- if (typeof raw.id !== "string" || typeof raw.title !== "string") return null;
-
- return {
- id: raw.id,
- title: raw.title,
- description: typeof raw.description === "string" ? raw.description : "",
- items: Array.isArray(raw.items)
- ? raw.items.filter((i): i is string => typeof i === "string")
- : [],
- // "NEXT" is a blueprint written when the board had three stages; it meant "not now", which is
- // what "LATER" means.
- stage: raw.stage === "NEXT" || raw.stage === "LATER" ? "LATER" : "NOW",
- roleIds: Array.isArray(raw.roleIds)
- ? raw.roleIds.filter((i): i is string => typeof i === "string")
- : [],
- position: typeof raw.position === "number" ? raw.position : 0,
- afterId: typeof raw.afterId === "string" ? raw.afterId : null,
- };
-}
-
-function read(projectId: string): CardBlueprint[] {
- try {
- const raw = window.localStorage.getItem(storageKey(projectId));
- if (!raw) return [];
-
- const parsed = JSON.parse(raw) as Stored;
- if (parsed?.version !== STORAGE_VERSION || !Array.isArray(parsed.blueprints)) return [];
-
- return parsed.blueprints
- .map(toBlueprint)
- .filter((blueprint): blueprint is CardBlueprint => blueprint !== null)
- .sort((a, b) => a.position - b.position);
- } catch {
- return [];
- }
-}
-
-function write(projectId: string, blueprints: CardBlueprint[]): void {
- window.localStorage.setItem(
- storageKey(projectId),
- JSON.stringify({ version: STORAGE_VERSION, blueprints } satisfies Stored),
- );
-}
-
-export const cardBlueprintService = {
- /**
- * Every card blueprint on a project, in the order the cards will be created.
- *
- * @param projectId The project the blueprints belong to.
- */
- list(projectId: string): Promise {
- return Promise.resolve(read(projectId));
- },
-
- /**
- * Writes a blueprint, creating it when `id` is null and replacing it otherwise.
- *
- * A new blueprint goes to the end: it was written last, and inserting it anywhere else would be
- * this function guessing at an order the PM has a control for.
- *
- * @returns The blueprint as stored, with its id and position filled in.
- */
- save(projectId: string, id: string | null, draft: CardBlueprintDraft): Promise {
- const blueprints = read(projectId);
-
- if (id) {
- const existing = blueprints.find((blueprint) => blueprint.id === id);
- if (!existing) {
- return Promise.reject(new Error(`No card blueprint ${id} on project ${projectId}`));
- }
-
- const updated = { ...existing, ...draft };
- write(
- projectId,
- blueprints.map((blueprint) => (blueprint.id === id ? updated : blueprint)),
- );
-
- return Promise.resolve(updated);
- }
-
- const created: CardBlueprint = {
- ...draft,
- id: `bp-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
- position: blueprints.length,
- };
- write(projectId, [...blueprints, created]);
-
- return Promise.resolve(created);
- },
-
- /**
- * Removes a blueprint, and unhooks anything that was waiting on it.
- *
- * A dangling `afterId` would leave a card blocked by a blueprint that no longer exists, which is
- * a block nobody can clear — so the removal takes the edge with it.
- */
- remove(projectId: string, id: string): Promise {
- const blueprints = read(projectId)
- .filter((blueprint) => blueprint.id !== id)
- .map((blueprint) => (blueprint.afterId === id ? { ...blueprint, afterId: null } : blueprint));
-
- write(
- projectId,
- blueprints.map((blueprint, index) => ({ ...blueprint, position: index })),
- );
-
- return Promise.resolve();
- },
-
- /** Puts the blueprints in this order. Sends the whole order, the way the board's reorder does. */
- reorder(projectId: string, ids: string[]): Promise {
- const byId = new Map(read(projectId).map((blueprint) => [blueprint.id, blueprint]));
- const ordered = ids
- .map((id) => byId.get(id))
- .filter((blueprint): blueprint is CardBlueprint => blueprint !== undefined)
- .map((blueprint, index) => ({ ...blueprint, position: index }));
-
- write(projectId, ordered);
-
- return Promise.resolve();
- },
-};
diff --git a/src/features/card-blueprints/types.ts b/src/features/card-blueprints/types.ts
deleted file mode 100644
index aed3c7260..000000000
--- a/src/features/card-blueprints/types.ts
+++ /dev/null
@@ -1,82 +0,0 @@
-import type { BoardStage } from "../board/layout/boardStructure";
-
-/**
- * A card a PM wants every new hire of some role to start with.
- *
- * The board fills itself from two directions. What the *system* knows goes on it automatically —
- * the path, the arrival steps, the open work — and what the *buddy* judges worth keeping is placed
- * in conversation. Neither covers what the team knows: that a backend hire needs the on-call rota
- * explained in week one, that everybody reads the incident write-up before touching deploys. That
- * lives in a PM's head, gets said once per hire, and is the first thing forgotten in a busy month.
- *
- * A blueprint is that knowledge written down once. It says what the card is called, what is on it,
- * when it is due, which roles it applies to, and what has to be finished before it. A hire's board
- * is then seeded from the blueprints their roles match — the same list every time, without anybody
- * having to remember it.
- *
- * Deliberately *not* the same thing as the AI service's onboarding blueprints, which describe the
- * phases and steps of a generated path. Those are drafted from the project's corpus and regenerated
- * as it moves; these are written by a person and change only when that person changes them. The
- * names collide because both are templates; nothing else about them does.
- */
-export type CardBlueprint = {
- id: string;
- /** What the card will be called on the hire's board. */
- title: string;
- /** One line under the title, saying why this card is here. Optional and often worth it. */
- description: string;
- /** The lines on the checklist, in order. A blueprint with no lines is a card with a title only. */
- items: string[];
- /** When it is due — the same stages the board sorts and folds by. */
- stage: BoardStage;
- /**
- * Which project roles get this card. Empty means everybody on the project.
- *
- * Ids rather than names: a role renamed from "Backend Dev" to "Platform Engineer" should keep its
- * blueprints, and matching on the name is how a rename silently empties somebody's board.
- */
- roleIds: string[];
- /** Where it sits among the blueprints, which is the order the cards are created in. */
- position: number;
- /**
- * The blueprint that has to be finished before this one, or null.
- *
- * Becomes a real dependency on the hire's board: their card waits on the card the other blueprint
- * produced. This is where "read the runbook before you deploy" stops being something a PM says
- * and starts being something the board holds.
- */
- afterId: string | null;
-};
-
-/** A blueprint as it is written, before it has an id or a position. */
-export type CardBlueprintDraft = Omit;
-
-/** An empty draft, so the editor opens on something rather than on nulls. */
-export const EMPTY_DRAFT: CardBlueprintDraft = {
- title: "",
- description: "",
- items: [],
- stage: "NOW",
- roleIds: [],
- afterId: null,
-};
-
-/**
- * The blueprints that apply to a hire holding these roles, in order.
- *
- * A blueprint with no roles applies to everyone: "read the incident write-up" is not a backend
- * thing, and forcing a PM to tick every role to say so would mean forgetting one whenever a role is
- * added later.
- */
-export function blueprintsForRoles(
- blueprints: CardBlueprint[],
- roleIds: readonly string[],
-): CardBlueprint[] {
- return [...blueprints]
- .filter(
- (blueprint) =>
- blueprint.roleIds.length === 0 ||
- blueprint.roleIds.some((roleId) => roleIds.includes(roleId)),
- )
- .sort((a, b) => a.position - b.position);
-}
diff --git a/src/features/chatbot/components/ChatComposer.tsx b/src/features/chatbot/components/ChatComposer.tsx
index b98fa6bd5..a8aef771d 100644
--- a/src/features/chatbot/components/ChatComposer.tsx
+++ b/src/features/chatbot/components/ChatComposer.tsx
@@ -165,6 +165,27 @@ export function ChatComposer({
};
const blocked = rangeInvalid || !hasProject;
+ /*
+ The last value typed into the box, so a value set *from outside* can be told apart.
+
+ A suggestion chip fills the composer without touching it, and a hire who picked one and pressed
+ Enter found nothing sent. Owning the focus here, rather than in each page that can fill the box,
+ means every way of handing text over behaves the same: any value that did not come from typing
+ takes the caret, behind the text, so Enter sends it and typing adds to it.
+ */
+ const typedRef = useRef(value);
+
+ useEffect(() => {
+ if (value === typedRef.current) return;
+ typedRef.current = value;
+ // Cleared after a send: the page blurs the box on purpose so Space can start the game.
+ if (!value) return;
+ const element = textareaRef.current;
+ if (!element) return;
+ element.focus();
+ element.setSelectionRange(element.value.length, element.value.length);
+ }, [value, textareaRef]);
+
const filterButtonRef = useRef(null);
const popoverRef = useRef(null);
@@ -582,6 +603,7 @@ export function ChatComposer({
value={value}
rows={1}
onChange={(e) => {
+ typedRef.current = e.currentTarget.value;
onChange(e.currentTarget.value);
e.currentTarget.style.height = "auto";
e.currentTarget.style.height = `${e.currentTarget.scrollHeight}px`;
diff --git a/src/features/onboarding-metrics/components/HireTimelineCard.tsx b/src/features/onboarding-metrics/components/HireTimelineCard.tsx
index 25a94e41d..8e77277a5 100644
--- a/src/features/onboarding-metrics/components/HireTimelineCard.tsx
+++ b/src/features/onboarding-metrics/components/HireTimelineCard.tsx
@@ -14,7 +14,7 @@ type HireTimelineCardProps = {
};
/**
- * One hire's onboarding timeline: joined → task claimed → work submitted → first
+ * One hire's contribution timeline: joined → task claimed → work submitted → first
* response → accepted, with the gap between each pair of moments that has actually
* happened. An unreached moment is a hollow, dashed dot and a dash, never a zero.
*
diff --git a/src/features/onboarding-metrics/components/OnboardingMetricsPage.tsx b/src/features/onboarding-metrics/components/OnboardingMetricsPage.tsx
index cfee51ffa..9fcd9981e 100644
--- a/src/features/onboarding-metrics/components/OnboardingMetricsPage.tsx
+++ b/src/features/onboarding-metrics/components/OnboardingMetricsPage.tsx
@@ -121,8 +121,12 @@ function OnboardingMetricsSkeleton() {
}
/**
- * The PM readout for the numbers the onboarding redesign is judged on:
- * time-to-first-accepted-work, response latency, and who is stalled. The
+ * The PM readout for how a project's people get their work in:
+ * time-to-first-accepted-work, response latency, and whose work is stalled.
+ *
+ * Not onboarding progress. Onboarding is the path a PM's blueprint prescribes, and how far a hire
+ * is along it is on the team pages; this is the contribution side beside it. The route and the
+ * file names still say "onboarding" from before that split. The
* aggregates lead, and the per-hire timelines follow, stalled first.
*
* Deliberately a measurement readout, not another dashboard: no completion
@@ -180,12 +184,12 @@ export function OnboardingMetricsPage() {
pendingRefreshRef.current = false;
setRefreshing(false);
if (error || refetchError) {
- toast.error("Couldn't refresh onboarding metrics", { description: "Try again shortly." });
+ toast.error("Couldn't refresh contribution metrics", { description: "Try again shortly." });
return;
}
if (!metrics || metrics.memberCount === 0) return;
if (!hasActivity(metrics.hires)) {
- toast.info("No onboarding activity yet", {
+ toast.info("No contribution activity yet", {
description:
"Nothing has happened on this project yet — that's different from nobody being here.",
});
@@ -268,10 +272,10 @@ export function OnboardingMetricsPage() {
);
return (
-
+
@@ -283,11 +287,11 @@ export function OnboardingMetricsPage() {
) : loading ? null : error ? (
} title="Not available right now">
- The onboarding metrics couldn't be loaded. Try again in a moment.
+ The contribution metrics couldn't be loaded. Try again in a moment.
) : !metrics || metrics.memberCount === 0 ? (
} title="No hires yet">
- Once people join this project, their onboarding shows up here.
+ Once people join this project, their contributions show up here.
) : !hasActivity(metrics.hires) ? (
} title="No data yet">
diff --git a/src/features/onboarding/buddyDrafts.ts b/src/features/onboarding/buddyDrafts.ts
new file mode 100644
index 000000000..464bddfcc
--- /dev/null
+++ b/src/features/onboarding/buddyDrafts.ts
@@ -0,0 +1,96 @@
+// ============================================================
+// features/onboarding/buddyDrafts.ts
+// ============================================================
+// The first sentence of a conversation about something on the
+// hire's path. Pre-filled into the buddy's composer, never sent
+// for them.
+// ============================================================
+
+import type {
+ OnboardingPhaseEndpoint,
+ OnboardingQuestionEndpoint,
+ OnboardingStepEndpoint,
+} from "./types";
+
+/**
+ * Taking what you are looking at on your path into the conversation.
+ *
+ * The board already works this way (see `AskTheBuddy`): a surface seeds a question and the mentor
+ * answers it with its own tools, which is why a card needs no action machinery of its own. The path
+ * is the surface that wanted it most and did not have it — a hire stuck on a step, or on a question
+ * they have now got wrong twice, was looking at the one page in the product with nobody to ask.
+ *
+ * **Written in the hire's voice, and as an opening rather than an instruction.** The draft lands in
+ * the composer for them to change before it goes; a sentence that reads like a command from the page
+ * is one they stop trusting as their own. That is also why these say what the hire wants rather than
+ * what the mentor should do: the mentor has the path in front of it either way.
+ *
+ * Kept out of the components so the wording is in one place and the same subject always opens the
+ * same way — the mentor's replies are inconsistent enough without the questions varying too.
+ */
+
+/** How much of somebody else's sentence is quoted into a draft before it is cut. */
+const QUOTE_LIMIT = 120;
+
+/** A quotable snippet of a title or a question, cut on a word boundary where there is one. */
+function snippet(text: string): string {
+ const trimmed = text.trim();
+ if (trimmed.length <= QUOTE_LIMIT) return trimmed;
+
+ const cut = trimmed.slice(0, QUOTE_LIMIT);
+ const lastSpace = cut.lastIndexOf(" ");
+ return `${lastSpace > QUOTE_LIMIT / 2 ? cut.slice(0, lastSpace) : cut}…`;
+}
+
+/** Opening a conversation about the phase the hire is standing in. */
+export function askAboutPhase(phase: OnboardingPhaseEndpoint): string {
+ return `I'm on the "${snippet(phase.title)}" phase of my onboarding. Can you walk me through what it's for and where I should start?`;
+}
+
+/**
+ * Opening a conversation about a phase that generated nothing.
+ *
+ * The case the buddy exists for on this page. An AI-enhanced phase whose project material was too
+ * thin is honestly left empty rather than filled with invented advice, which leaves the hire with a
+ * warning badge and nothing to do about it. Its title still says what it was meant to cover, so the
+ * conversation can — and the mentor can offer to put the result on their path.
+ */
+export function askAboutEmptyPhase(phaseTitle: string): string {
+ return `The "${snippet(phaseTitle)}" phase of my onboarding came back empty — nothing was generated for it. Can we work out together what it should contain for me?`;
+}
+
+/**
+ * The same opening for every phase that came back empty at once, so a hire with several is not
+ * handed a draft about only the first of them.
+ */
+export function askAboutEmptyPhases(phaseTitles: readonly string[]): string {
+ if (phaseTitles.length === 1) return askAboutEmptyPhase(phaseTitles[0]);
+ const names = phaseTitles.map((title) => `"${snippet(title)}"`);
+ const listed = `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}`;
+ return `The ${listed} phases of my onboarding came back empty — nothing was generated for them. Can we work out together what they should contain for me?`;
+}
+
+/** Opening a conversation about one step. */
+export function askAboutStep(step: Pick): string {
+ return `I'm on the onboarding step "${snippet(step.title)}". Can you help me get going on it?`;
+}
+
+/**
+ * Opening a conversation about a knowledge question.
+ *
+ * Says out loud that the hire wants to understand it rather than be told the answer. The mentor is
+ * not given the correct answer and will say so if asked — but a hire who opens by asking for it gets
+ * a refusal as their first experience of the feature, and this is the cheapest way to not start
+ * there.
+ */
+export function askAboutQuestion(question: OnboardingQuestionEndpoint, phaseTitle: string): string {
+ return `I'm stuck on the knowledge question "${snippet(question.question)}" in "${snippet(phaseTitle)}". Can you go through the material with me? I'd rather work the answer out than be told it.`;
+}
+
+/** Opening a conversation about a question the hire has just got wrong. */
+export function askAboutWrongAnswer(
+ question: OnboardingQuestionEndpoint,
+ phaseTitle: string,
+): string {
+ return `I just got the knowledge question "${snippet(question.question)}" in "${snippet(phaseTitle)}" wrong. Can you go through the material with me so I actually understand it before I try again?`;
+}
diff --git a/src/features/onboarding/components/StepOriginBadge.tsx b/src/features/onboarding/components/StepOriginBadge.tsx
index 59baf6ed4..248e33f8e 100644
--- a/src/features/onboarding/components/StepOriginBadge.tsx
+++ b/src/features/onboarding/components/StepOriginBadge.tsx
@@ -1,18 +1,61 @@
-import { UserRound } from "lucide-react";
+import { MessageCircle, PenLine, UserRound } from "lucide-react";
import { Badge } from "../../../components/ui/Badge";
import type { OnboardingStepEndpoint } from "../types";
type StepOriginBadgeProps = {
step: OnboardingStepEndpoint;
+ /**
+ * Who is looking. The hire reads "you" and "your buddy"; a PM reviewing somebody else's path
+ * would read those as being about themselves, so the reviewer view names the hire instead.
+ */
+ viewer?: "hire" | "reviewer";
};
-export function StepOriginBadge({ step }: StepOriginBadgeProps) {
- if (step.isAiAssisted !== false) return null;
+/**
+ * Where a step came from, said on the card.
+ *
+ * It used to read this off `isAiAssisted`, which has two values and three answers to give: anything
+ * not AI-generated was labelled "Custom step by PM", so a step the hire wrote themselves — and later
+ * a step their buddy proposed — both arrived claiming the team had prescribed it. A hire who cannot
+ * tell what their team requires from what they agreed to in a chat has lost the distinction this
+ * badge exists for.
+ *
+ * A generated step wears nothing. It is the ordinary case, and the whole path would otherwise carry
+ * the same badge on every card, which is a label for the page rather than for a step.
+ *
+ * **The `isAiAssisted` fallback stays**, for two kinds of step that arrive as `GENERATED` with
+ * `isAiAssisted` false: a step copied from a blueprint step the PM wrote by hand (the copy keeps the
+ * blueprint's flag), and a row written before `origin` existed. Both were authored by a person on
+ * the team, and both keep the badge they have always had.
+ */
+export function StepOriginBadge({ step, viewer = "hire" }: StepOriginBadgeProps) {
+ if (step.origin === "BUDDY") {
+ return (
+
+
+ {viewer === "hire" ? "Added with your buddy" : "Added with the buddy"}
+
+ );
+ }
- return (
-
-
- Custom step by PM
-
- );
+ if (step.origin === "HIRE") {
+ return (
+
+
+ {viewer === "hire" ? "You added this" : "Added by the hire"}
+
+ );
+ }
+
+ // PM, or a hand-written blueprint copy or pre-`origin` row -- see above.
+ if (step.origin === "PM" || step.isAiAssisted === false) {
+ return (
+
+
+ Custom step by PM
+
+ );
+ }
+
+ return null;
}
diff --git a/src/features/onboarding/components/journey/JourneyGraph.tsx b/src/features/onboarding/components/journey/JourneyGraph.tsx
index de2d421e7..e6c175c4f 100644
--- a/src/features/onboarding/components/journey/JourneyGraph.tsx
+++ b/src/features/onboarding/components/journey/JourneyGraph.tsx
@@ -30,6 +30,7 @@ import {
type JourneyEdgeTone,
} from "../../graph/JourneyCanvas";
import { ItemGlyph, ItemNodeCard, PhaseNodeCard } from "../../graph/JourneyNodeCards";
+import { itemNumbers } from "../../itemNumbers";
import {
ITEM_LAYOUT,
ITEM_NODE_SIZE,
@@ -431,6 +432,8 @@ export function JourneyGraph({
const selectedItem = items.find((item) => item.id === selectedItemId) ?? null;
const focusedItem = entersItems ? (items.find((item) => item.id === openItemId) ?? null) : null;
const progress = phaseProgress(openPhase);
+ // The numbers the list and the buddy use for these items, so a node can be named by the one on it.
+ const numbers = itemNumbers(openPhase);
return (
)}
/>
diff --git a/src/features/onboarding/components/journey/PhaseItemList.tsx b/src/features/onboarding/components/journey/PhaseItemList.tsx
index 81309e0cf..26fd8931f 100644
--- a/src/features/onboarding/components/journey/PhaseItemList.tsx
+++ b/src/features/onboarding/components/journey/PhaseItemList.tsx
@@ -5,11 +5,13 @@ import { Button } from "../../../../components/ui/Button";
import {
formatMinutes,
itemState,
+ linkedCardId,
orderedPhaseItems,
waitingOn,
type PhaseItem,
} from "../../journey";
import { ItemFlags, ItemGlyph } from "../../graph/JourneyNodeCards";
+import { itemNumbers } from "../../itemNumbers";
import { itemKindLabel, itemStateLabel, primaryActionLabel } from "../../graph/nodeLabels";
import type { OnboardingPhaseEndpoint } from "../../types";
@@ -18,6 +20,10 @@ type Props = {
nextItemId: string | null;
/** The item unfolded in place, if any. */
expandedItemId: string | null;
+ /** The item a link from the buddy landed on, lit up until `onLinkHighlightEnd` says it played. */
+ linkedItemId?: string | null;
+ /** The light on `linkedItemId` has played; the page takes it away so it does not play again. */
+ onLinkHighlightEnd?: () => void;
onToggle: (item: PhaseItem) => void;
/** Start, continue or answer: opens the item in place, starting a step that was not started. */
onPrimary: (item: PhaseItem) => void;
@@ -37,11 +43,16 @@ export function PhaseItemList({
phase,
nextItemId,
expandedItemId,
+ linkedItemId = null,
+ onLinkHighlightEnd,
onToggle,
onPrimary,
renderExpanded,
}: Props) {
const items = orderedPhaseItems(phase);
+ // The numbers the buddy uses for the same items. Labels rather than a count down this list: the
+ // list reads in graph order, the numbers stay put so "#3" means one item on every surface.
+ const numbers = itemNumbers(phase);
if (items.length === 0) {
return (
@@ -63,10 +74,14 @@ export function PhaseItemList({
const minutes = item.kind === "step" ? item.step.estimatedMinutes : null;
const muted = state === "done" || state === "skipped" || state === "locked";
const canUnfold = state !== "locked";
+ const isLinked = linkedItemId === item.id;
return (
- ) : null}
+ ) : (
+ // Before an attempt, and quiet: guessing costs nothing here, so this is an offer rather
+ // than a nudge. Louder on a question already answered wrong on an earlier visit.
+
+ )}
Date.now());
+ // Bumped when the buddy changed the path, so the open step re-reads its tasks and status: ticking
+ // a line off in the conversation must show on the checklist behind the dock.
+ const [buddyChanges, setBuddyChanges] = useState(0);
+
+ useEffect(() => onBuddyPathChanged(() => setBuddyChanges((count) => count + 1)), []);
useEffect(() => {
const timer = window.setInterval(() => setNow(Date.now()), 60_000);
@@ -106,7 +114,27 @@ export function StepWorkspace({
onSkipAnswerSeenRef.current = onSkipAnswerSeen;
}, [onSkipAnswerSeen]);
+ // What the last read brought, so a re-read can tell the member's own typing from the server's
+ // text, and the page's path refresh after a buddy change need not read the step a second time.
+ const loadedRef = useRef<{
+ stepId: string;
+ buddyChanges: number;
+ status: string;
+ skipReason: string;
+ comment: string;
+ } | null>(null);
+
useEffect(() => {
+ const last = loadedRef.current;
+ // The buddy's change is announced here and to the page at once; the page's refresh then moves
+ // `stepStatus` to what the read for `buddyChanges` already brought.
+ if (
+ last?.stepId === stepId &&
+ last.buddyChanges === buddyChanges &&
+ last.status === stepStatus
+ ) {
+ return;
+ }
let cancelled = false;
Promise.all([
onboardingService.fetchStep(stepId),
@@ -127,12 +155,20 @@ export function StepWorkspace({
}
setTasks([...fetchedTasks].sort((left, right) => left.position - right.position));
setResources(fetchedResources);
- setSkipReason(detail.skip?.reason ?? "");
+ // A re-read of the same step keeps what the member is typing: it runs whenever the buddy
+ // changes anything on the path, and used to wipe a half-written skip reason or comment.
+ const sameStep = last?.stepId === stepId;
+ const skipReason = detail.skip?.reason ?? "";
+ const comment = detail.feedback?.comment ?? "";
+ setSkipReason((current) =>
+ sameStep && current !== last.skipReason ? current : skipReason,
+ );
if (detail.feedback) {
setHelpful(detail.feedback.helpful);
- setComment(detail.feedback.comment ?? "");
+ setComment((current) => (sameStep && current !== last.comment ? current : comment));
setFeedbackSent(true);
}
+ loadedRef.current = { stepId, buddyChanges, status: detail.status, skipReason, comment };
})
.catch((reason: unknown) => {
if (!cancelled) setError(reason instanceof Error ? reason.message : "Unknown error");
@@ -140,7 +176,7 @@ export function StepWorkspace({
return () => {
cancelled = true;
};
- }, [stepId, stepStatus]);
+ }, [stepId, stepStatus, buddyChanges]);
if (error) {
return (
@@ -288,6 +324,14 @@ export function StepWorkspace({
{step.description}
) : null}
+ {/* Where a hire sits when they are stuck on a step, so the way out of being stuck belongs
+ here. Not once it is behind them: there is nothing left to be stuck on. */}
+ {!isBehind ? (
+
+ ) : null}
{/* ── Where a skip request or feedback stands -- said up front, in colour ── */}
diff --git a/src/features/onboarding/graph/JourneyNodeCards.tsx b/src/features/onboarding/graph/JourneyNodeCards.tsx
index dcb9b2629..47cea8142 100644
--- a/src/features/onboarding/graph/JourneyNodeCards.tsx
+++ b/src/features/onboarding/graph/JourneyNodeCards.tsx
@@ -281,12 +281,18 @@ export function ItemNodeCard({
render,
isNext = false,
showUpdates = false,
+ number,
}: {
item: PhaseItem;
state: ItemState;
render: JourneyNodeRenderState;
isNext?: boolean;
showUpdates?: boolean;
+ /**
+ * The number the item carries on the list and in the buddy's replies (see `itemNumbers`), so a
+ * node here can be talked about by the number the hire can see on it.
+ */
+ number?: number;
}) {
const minutes = item.kind === "step" ? item.step.estimatedMinutes : null;
const isQuestion = item.kind === "question";
@@ -316,6 +322,9 @@ export function ItemNodeCard({
: "text-app-text"
}`}
>
+ {number !== undefined ? (
+ #{number}
+ ) : null}
{item.title}
diff --git a/src/features/onboarding/itemNumbers.ts b/src/features/onboarding/itemNumbers.ts
new file mode 100644
index 000000000..dbad47937
--- /dev/null
+++ b/src/features/onboarding/itemNumbers.ts
@@ -0,0 +1,34 @@
+// ============================================================
+// features/onboarding/itemNumbers.ts
+// ============================================================
+// The number a phase's steps and questions carry on screen, so
+// a hire can say "let's do 3" to their buddy and mean this one.
+// ============================================================
+
+import type { OnboardingPhaseEndpoint } from "./types";
+
+/**
+ * The number each item of a phase is shown with, keyed by id.
+ *
+ * **Steps first in position order, then questions in position order.** That is the order this page
+ * lists them in, and the order the buddy's path tool numbers them in — the same rule stated twice,
+ * once per language, because the number is the whole point: a hire says "3" and the mentor has to
+ * land on the item they were looking at. If the page's own order ever changes, `BuddyPathTools`
+ * changes with it or the numbers start lying.
+ *
+ * Not `position` itself. Steps and questions carry their own positions underneath, so two items can
+ * share one, and a hire counting down a single visible list would be right while the data disagreed.
+ *
+ * A number is not an identity — the buddy also gets each item's id and its link, and those are what
+ * an action is aimed at. This exists so a person does not have to retype a title.
+ */
+export function itemNumbers(phase: OnboardingPhaseEndpoint): Map {
+ const steps = [...phase.steps].sort((a, b) => a.position - b.position);
+ const questions = [...phase.questions].sort((a, b) => a.position - b.position);
+
+ return new Map(
+ [...steps.map((step) => step.id), ...questions.map((question) => question.id)].map(
+ (id, index) => [id, index + 1],
+ ),
+ );
+}
diff --git a/src/features/onboarding/journey.ts b/src/features/onboarding/journey.ts
index 1e8303f65..374201f48 100644
--- a/src/features/onboarding/journey.ts
+++ b/src/features/onboarding/journey.ts
@@ -91,6 +91,11 @@ export function phaseItems(phase: OnboardingPhaseEndpoint): PhaseItem[] {
return [...steps, ...questions];
}
+/** The DOM id of a step or question row, which a link from the buddy scrolls to. */
+export function linkedCardId(itemId: string): string {
+ return `onboarding-item-${itemId}`;
+}
+
/** A phase's items in the order its graph reads, top to bottom -- the list view's order. */
export function orderedPhaseItems(phase: OnboardingPhaseEndpoint): PhaseItem[] {
return orderByGraph(phaseItems(phase));
diff --git a/src/features/onboarding/types.ts b/src/features/onboarding/types.ts
index 78a091917..b36433944 100644
--- a/src/features/onboarding/types.ts
+++ b/src/features/onboarding/types.ts
@@ -47,11 +47,21 @@ export interface OnboardingStepSkip {
answerSeenAt?: string | null;
}
+/** Who put a step on a path — see the backend's `StepOrigin`. */
+export type StepOrigin = "GENERATED" | "PM" | "HIRE" | "BUDDY";
+
export interface OnboardingStepEndpoint {
id: string;
phaseId: string;
position: number;
isAiAssisted?: boolean;
+ /**
+ * Who put this step on the path.
+ *
+ * Optional because a row written before the column existed carries none; `StepOriginBadge` falls
+ * back to `isAiAssisted` for those, which is the only signal they have.
+ */
+ origin?: StepOrigin;
title: string;
description: string;
type: StepType;
diff --git a/src/features/pm-area/analysis/useProjectAnalysis.ts b/src/features/pm-area/analysis/useProjectAnalysis.ts
index 2f43f6265..71261e660 100644
--- a/src/features/pm-area/analysis/useProjectAnalysis.ts
+++ b/src/features/pm-area/analysis/useProjectAnalysis.ts
@@ -131,7 +131,7 @@ export type AnalysisPhase = "idle" | "running" | "done";
const TASKS: readonly Pick[] = [
{ id: "team", label: "Team & open items" },
- { id: "onboarding", label: "Onboarding metrics" },
+ { id: "onboarding", label: "Contribution metrics" },
{ id: "escalations", label: "Escalation inbox" },
{ id: "questions", label: "Recurring questions" },
{ id: "gaps", label: "Knowledge gaps" },
diff --git a/src/features/starter-work/components/StarterWorkTaskDetails.tsx b/src/features/starter-work/components/StarterWorkTaskDetails.tsx
index 28a8c4645..a32c4899e 100644
--- a/src/features/starter-work/components/StarterWorkTaskDetails.tsx
+++ b/src/features/starter-work/components/StarterWorkTaskDetails.tsx
@@ -248,7 +248,8 @@ export function StarterWorkTaskDetails({
Use as Task 0
- Handed to a new hire as their very first task, on any project.
+ Marks this as a gentle first task. A hint in the pool — it is not handed to
+ anybody.
{canAct ? (
diff --git a/src/features/starter-work/types.ts b/src/features/starter-work/types.ts
index 568f2be98..37952522c 100644
--- a/src/features/starter-work/types.ts
+++ b/src/features/starter-work/types.ts
@@ -28,7 +28,7 @@ export type StarterWorkTask = {
status: ProposalStatus;
/** Whether a person has looked at this task. Unreviewed is claimable, just ranked lower. */
reviewed: boolean;
- /** Whether a PM has flagged this task as suitable for a hire's automatic first task (Task 0). */
+ /** Whether a PM has flagged this task as a good first one for somebody (Task 0). A hint, not a gate. */
taskZeroEligible: boolean;
/**
* Whether the issue had somebody on it when reconciliation last looked. Three-valued: null
diff --git a/src/features/team-management/components/detail/MemberJourneySection.tsx b/src/features/team-management/components/detail/MemberJourneySection.tsx
index e859e688d..4fae75e70 100644
--- a/src/features/team-management/components/detail/MemberJourneySection.tsx
+++ b/src/features/team-management/components/detail/MemberJourneySection.tsx
@@ -653,7 +653,7 @@ function StepFacts({ item, taskCount }: { item: PhaseItem; taskCount?: StepTaskC
const delta = actual && item.step.estimatedMinutes ? actual - item.step.estimatedMinutes : null;
return (
}
panelBackgroundClassName="bg-app-surface"
diff --git a/src/pages/BoardPage.tsx b/src/pages/BoardPage.tsx
index 31a9aaad8..aa1fda2c1 100644
--- a/src/pages/BoardPage.tsx
+++ b/src/pages/BoardPage.tsx
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { Link } from "react-router-dom";
import {
AlertCircle,
Check,
@@ -10,7 +11,6 @@ import {
Milestone,
Minimize2,
RefreshCw,
- Sparkles,
} from "lucide-react";
import { PageHeader } from "../components/layout/PageHeader";
import { Button } from "../components/ui/Button";
@@ -21,11 +21,9 @@ import { useSwipeableTabs } from "../hooks/useHorizontalWheelNavigation";
import { useBoard } from "../features/board/hooks/useBoard";
import { useBoardStructure } from "../features/board/hooks/useBoardStructure";
import { BoardChainPanel } from "../features/board/components/BoardChainPanel";
-import { useGeneratedPathCards } from "../features/board/hooks/useGeneratedPathCards";
import { AddCardForm, AddCardTriggers } from "../features/board/components/AddCardForm";
import type { AuthoredCardKind } from "../features/board/types";
import { BoardGrid } from "../features/board/components/BoardGrid";
-import { BoardPathNotes } from "../features/board/components/BoardPathNotes";
import { BoardPathWindow } from "../features/board/components/BoardPathWindow";
import { BoardSectionTabs } from "../features/board/components/BoardSectionNav";
import { BoardFilterTriggers } from "../features/board/components/BoardFilterTriggers";
@@ -36,7 +34,6 @@ import { BoardNextUp } from "../features/board/components/BoardNextUp";
import { BoardLocalOnlyNotice } from "../features/board/components/BoardLocalOnlyNotice";
import { nextUp } from "../features/board/layout/nextUp";
import { useProjectContext } from "../features/projects/useProjectContext";
-import { useAuth } from "../context/useAuth";
import { useToast } from "../context/useToast";
import { useFocusMode } from "../context/useFocusMode";
import { readCollapsedCards, writeCollapsedCards } from "../features/board/layout/collapsedCards";
@@ -90,20 +87,19 @@ import {
* memory and never replayed — so anything durable it showed you was gone by the next visit. This is
* where those things live instead. Chat is the conversation; this is the whiteboard beside it.
*
- * Per project, because what belongs on it is: the path, the open work, later the current task. The
- * project switcher is the same one the rest of the app uses, so the choice is remembered across
- * pages rather than being a setting of this one.
+ * Per project, because what belongs on it is: the open work, the current task, what the buddy
+ * remembers. The project switcher is the same one the rest of the app uses, so the choice is
+ * remembered across pages rather than being a setting of this one.
+ *
+ * **Not the onboarding.** That is the path on the Onboarding page, which a PM's blueprint prescribes
+ * and the buddy tutors along. The board used to carry a second one -- a rail from joining to a first
+ * accepted contribution, and a button that copied the path's steps into checklists -- and two plans
+ * that drift apart are worse than one.
*
* The shell is the app's page shell — banner header over `app-page-frame`, `PageHeader` for the
* title block, shared primitives for the actions and for every empty, loading and error state — so
* the board sits at the same gutter and reads with the same weight as Starter Work beside it.
*
- * **The path is lifted out of the grid into the header.** It is the one card that says where the
- * hire stands overall; every other card is a detail of some part of it, so it belongs above them
- * rather than competing with a checklist for a slot. It keeps its place in the board's order — the
- * grid renders the rest, and a reorder puts the path back at the index it came from, so lifting it
- * for display never quietly rewrites what the hire arranged.
- *
* **The board is now a process, not a pile.** Three things carry that, and none of them changes the
* board's own order:
*
@@ -114,9 +110,7 @@ import {
* - *sections* down the side, so a board of forty cards is read one part at a time.
*
* The reason for all three is the same. A board that shows everything at once is fine at eight
- * cards and unusable at forty, and forty is what a generated onboarding path produces. See
- * `layout/boardStructure.ts` for the model and `generation/pathToCards.ts` for where the cards come
- * from.
+ * cards and unusable at forty. See `layout/boardStructure.ts` for the model.
*/
/**
@@ -142,21 +136,16 @@ const FOLD_THRESHOLD = 8;
* Which cards the board is showing.
*
* Not a search and not a sort — the one cut worth making by hand is *who put this here*, which is
- * the one thing a card's content never says on its own. Three sources, and they partition the
- * board:
+ * the one thing a card's content never says on its own. Two sources, and they partition the board:
*
* - `buddy` — placed for the hire in conversation, contents read live.
- * - `team` — a card blueprint their PM wrote for everybody in this role.
- * - `mine` — everything else they own: their own notes and lists, and the steps of their own
- * personalised path. The path counts as theirs because it *is*: it was drafted for them, they
- * edit it, and nobody else on the project has the same one.
+ * - `mine` — everything they wrote themselves: their own notes, links and lists.
*
* Where a card sits in the process is a separate question, and the stages, the focus view and the
* section tabs answer that one.
*/
export function BoardPage() {
const { selectedProjectId, isLoading: projectsLoading } = useProjectContext();
- const { profile } = useAuth();
const toast = useToast();
const { isFocused, setFocused } = useFocusMode();
@@ -179,8 +168,6 @@ export function BoardPage() {
return () => window.removeEventListener("keydown", handleKeyDown);
}, [isFocused, setFocused]);
- /** The hire's roles on this project, which decide which of the team's blueprints reach them. */
- const roleIds = useMemo(() => (profile?.projectRoles ?? []).map((role) => role.id), [profile]);
const [isArranging, setIsArranging] = useState(false);
const [filter, setFilter] = useState("all");
const [sectionId, setSectionId] = useState(null);
@@ -493,14 +480,6 @@ export function BoardPage() {
});
}
- // The path is drawn above the board -- the window for where they stand, `BoardPathNotes` for
- // the two things the card says that nothing else does -- and the grid gets everything else. Its
- // index is kept so a reorder of the visible cards can put it back where it was: the board's
- // order is the hire's, and this is a display decision, not an edit to it.
- const pathIndex =
- board?.cards.findIndex((card) => card.content.kind === "PATH_TO_FIRST_CONTRIBUTION") ?? -1;
- const pathCard = pathIndex === -1 ? null : (board?.cards[pathIndex] ?? null);
-
/**
* Every card the board is holding, whatever the current view.
*
@@ -517,22 +496,13 @@ export function BoardPage() {
() =>
board?.cards.filter(
(card) =>
- card !== pathCard &&
- !pendingRemovals.has(card.id) &&
- (isTaskPoolShown || card.content.kind !== "TASK_POOL"),
+ !pendingRemovals.has(card.id) && (isTaskPoolShown || card.content.kind !== "TASK_POOL"),
) ?? [],
- [board, pathCard, pendingRemovals, isTaskPoolShown],
+ [board, pendingRemovals, isTaskPoolShown],
);
- const {
- structure,
- states,
- assignStage,
- assignGroupStage,
- toggleDone,
- setPredecessor,
- applyPlan,
- } = useBoardStructure(boardId, allCards);
+ const { structure, states, assignStage, assignGroupStage, toggleDone, setPredecessor } =
+ useBoardStructure(boardId, allCards);
/**
* The card whose run is being looked at, or null.
@@ -590,13 +560,6 @@ export function BoardPage() {
[allCards, states],
);
- /**
- * The provenance cuts worth offering on *this* board.
- *
- * "From your team" only appears once the team has actually prescribed something. An option that
- * can only ever come back empty is a promise the board cannot keep, and on an installation where
- * nobody has written a blueprint that is every board.
- */
/**
* Whether the board has been divided into anything worth navigating.
*
@@ -871,12 +834,7 @@ export function BoardPage() {
return cuts;
}, [allCards, filter, openStackIds, shownSectionId, sections, stacks]);
- const handleReorder = (cardIds: string[]) => {
- if (!pathCard || pathIndex === -1) return void reorder(cardIds);
- const next = [...cardIds];
- next.splice(Math.min(pathIndex, next.length), 0, pathCard.id);
- return void reorder(next);
- };
+ const handleReorder = (cardIds: string[]) => void reorder(cardIds);
/**
* Opens the planning mode, with nothing folded away.
@@ -897,82 +855,6 @@ export function BoardPage() {
setIsArranging(true);
}
- const { generate, generating } = useGeneratedPathCards();
-
- /**
- * Builds the hire's personalised onboarding path into cards, and files them.
- *
- * The cards are written server-side; the areas, stages and order between them are this client's
- * to keep, so both halves are applied here rather than left for the hire to arrange by hand. A
- * generated path that landed as forty loose cards would be the exact complaint this answers.
- */
- async function handleGenerate() {
- if (!selectedProjectId) return;
-
- const existingTitles = new Set(
- allCards.flatMap((card) =>
- card.content.kind === "CHECKLIST" && card.content.title ? [card.content.title] : [],
- ),
- );
-
- const result = await generate(selectedProjectId, roleIds, existingTitles);
-
- if (result === "NOTHING_TO_BUILD") {
- toast.info("Nothing to build from yet", {
- description:
- "Generate your onboarding path on the Onboarding page, or ask your PM to set up card blueprints.",
- });
- return;
- }
- if (result === "NOTHING_NEW") {
- toast.info("Your path is already on the board", {
- description: "Every step that isn't finished is already a card here.",
- });
- return;
- }
- if (result === "FAILED") {
- showErrorToast("Your path couldn't be built into cards", {
- description: "Nothing was changed — try again.",
- });
- return;
- }
-
- // One area per phase, named after it — and a second run adds to the area it made the first
- // time rather than making another one beside it. Without that, generating again after the PM
- // added a blueprint left two areas called "From your team", one holding the old cards and one
- // holding the new, which is the same card twice as far as anybody reading the board can tell.
- //
- // The index is in the id because a plan is applied inside a single millisecond and `Date.now()`
- // alone would mint the same id for every phase.
- const stamp = Date.now();
- const filed = [...groups];
- result.areas.forEach((area, index) => {
- const existing = filed.findIndex((group) => group.name === area.name);
- if (existing !== -1) {
- filed[existing] = {
- ...filed[existing],
- cardIds: [...filed[existing].cardIds, ...area.cardIds],
- };
-
- return;
- }
-
- filed.push({
- id: `group-path-${stamp}-${index}`,
- name: area.name,
- cardIds: area.cardIds,
- collapsed: false,
- });
- });
- saveGroups(filed);
- applyPlan(result.stages, result.chain);
-
- refresh();
- toast.success(`${result.cardCount} cards added from your path`, {
- description: "Grouped by phase, in the order the path puts them in.",
- });
- }
-
/**
* The gutters the page draws in.
*
@@ -995,7 +877,7 @@ export function BoardPage() {
subtitle={
isArranging
? "Say when each card is due and what it waits on."
- : "Where your onboarding stays put between conversations."
+ : "Where your work stays put between conversations."
}
actions={
isArranging ? (
@@ -1008,16 +890,6 @@ export function BoardPage() {
) : (
<>
-
{/* The one switch from the rail worth a copy here, for the widths where
there is no margin to put a rail in. `lg:hidden` rather than a second
implementation: one state, two places it can be reached from. */}
@@ -1055,9 +927,6 @@ export function BoardPage() {
page; this is about the work, and it belongs where the work is.
*/}
{isPathShown && }
- {pathCard?.content.kind === "PATH_TO_FIRST_CONTRIBUTION" && (
-
- )}
{/* The page keeps a 10rem margin either side from `lg` up, and on this page it is dead
space: the board is a column of cards and the margin is where a hand rests. So the
offers live there — always in reach, never in the way, and out of the row above the
@@ -1297,26 +1166,22 @@ export function BoardPage() {
{/* A board with nothing on it is the first thing a new hire sees, and an empty page
cannot say what the board is *for*. Named after what it will hold rather than after
- its own emptiness — and it points at the two things that fill it: the path, and the
- row of buttons directly above. */}
+ its own emptiness — and it says where the onboarding is, because it is not here. */}
{allCards.length === 0 && (
}
title="Nothing on your board yet"
- action={
-
- }
>
This is where things stay put between conversations — the task you are on, work
- worth picking up, what your buddy remembers. Build your onboarding path into
- checklists here, and add a note, a link or a list of your own at any time.
+ worth picking up, what your buddy remembers. Add a note, a link or a list of
+ your own at any time. Your onboarding itself is on the{" "}
+
+ Onboarding page
+
+ .
)}
diff --git a/src/pages/OnBoardingPage.tsx b/src/pages/OnBoardingPage.tsx
index f3eda7d8d..b677a37f8 100644
--- a/src/pages/OnBoardingPage.tsx
+++ b/src/pages/OnBoardingPage.tsx
@@ -22,7 +22,7 @@ import {
} from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useQueryClient } from "@tanstack/react-query";
-import { Link, useLocation, useNavigate, useParams } from "react-router-dom";
+import { Link, useLocation, useNavigate, useParams, useSearchParams } from "react-router-dom";
import { PageHeader } from "../components/layout/PageHeader";
import { PageShell } from "../components/layout/PageShell";
import { Badge } from "../components/ui/Badge";
@@ -50,12 +50,14 @@ import { ProgressRing } from "../features/onboarding/graph/JourneyNodeCards";
import { usePathRevealMoment } from "../features/onboarding/hooks/usePathRevealMoment";
import {
blockingPhases,
+ isSkipPending,
itemState,
pathProgress,
phaseItems,
phaseProgress,
phaseState,
phasesUnlockedBy,
+ linkedCardId,
sortedPhases,
waitingOn,
type PhaseItem,
@@ -69,6 +71,13 @@ import type {
} from "../features/onboarding/types";
import { useMoments } from "../features/moments";
import { useProjectContext } from "../features/projects/useProjectContext";
+import { AskTheBuddy } from "../features/buddy/components/AskTheBuddy";
+import { onBuddyPathChanged } from "../features/buddy/aiBuddyBus";
+import {
+ askAboutEmptyPhase,
+ askAboutEmptyPhases,
+ askAboutPhase,
+} from "../features/onboarding/buddyDrafts";
import { ApiError } from "../services/apiClient";
import { onboardingGraphService } from "../services/onboardingGraphService";
import { onboardingService } from "../services/onboardingService";
@@ -89,6 +98,9 @@ import {
type LoadingState = "loading" | "empty" | "success" | "error";
type ViewMode = "list" | "graph";
+/** How a step came to be opened: by its address, or by the member on the page. */
+type StartOptions = { byAddress?: boolean };
+
const VIEW_ORDER: readonly ViewMode[] = ["list", "graph"];
type NavigationState = {
@@ -165,6 +177,21 @@ export function OnBoardingPage() {
const focusItemId = routeStepId ?? navigationState?.focusQuestionId;
// Set by the board's "where you are" strip when a phase on it is pressed.
const openPhaseId = navigationState?.openPhaseId;
+ /**
+ * Where a link from the buddy points: `?step=`, `?question=` or `?phase=`.
+ *
+ * The mentor is handed each item's link so it can write "you are on [#3](...)" and have that be
+ * clickable. In the URL rather than in router state because the model writes it into text the hire
+ * can copy, keep or open in a second tab, and state survives none of that.
+ *
+ * A link *lands* rather than starts: the phase opens, the page scrolls to the item and lights it up
+ * briefly. Unlike `/onboarding/:stepId`, nothing is unfolded or started -- following a link in a
+ * conversation is a way of finding something, and starting it is the hire's own click.
+ */
+ const [searchParams, setSearchParams] = useSearchParams();
+ const linkedStepId = searchParams.get("step");
+ const linkedQuestionId = searchParams.get("question");
+ const linkedPhaseId = searchParams.get("phase");
const [path, setPath] = useState(null);
const [loadingState, setLoadingState] = useState("loading");
@@ -262,6 +289,11 @@ export function OnBoardingPage() {
}
// Set when the page itself moves the member on, so the item they land on is scrolled to.
const scrollToItemRef = useRef(focusItemId ?? null);
+ // The item a buddy link landed on, for as long as its light plays. `key` changes per arrival, so
+ // following the same link again scrolls to it again. Cleared when the light has played or the
+ // member moves to another phase, so neither the scroll nor the light comes back on its own later.
+ const [linkHighlight, setLinkHighlight] = useState<{ id: string; key: string } | null>(null);
+ const clearLinkHighlight = useCallback(() => setLinkHighlight(null), []);
usePathRevealMoment(loadingState === "success" ? path : null);
@@ -318,6 +350,16 @@ export function OnBoardingPage() {
}
}, [applyPath, toast]);
+ /**
+ * Re-reads the path after the buddy changed it.
+ *
+ * The buddy lives in a dock over this page, which is where a hire most likely is while talking
+ * about their path. Without this, confirming "mark this step as done" in the conversation left the
+ * list behind the dock still showing the step open. Told rather than polled; see
+ * `announceBuddyPathChanged`.
+ */
+ useEffect(() => onBuddyPathChanged(() => void refreshPath()), [refreshPath]);
+
// A generation that finished -- here or while the user was elsewhere -- means there is a new path.
// Read fresh rather than taken from the generation: the hire may have started working on it before
// coming back here, and the stream's copy knows nothing of that.
@@ -372,15 +414,30 @@ export function OnBoardingPage() {
? { kind: "question" as const, id: focusQuestionId }
: wantsChooser
? { kind: "choose" as const, id: "" }
- : null,
- [focusQuestionId, routeStepId, wantsChooser],
+ : linkedStepId
+ ? { kind: "link-step" as const, id: linkedStepId }
+ : linkedQuestionId
+ ? { kind: "link-question" as const, id: linkedQuestionId }
+ : linkedPhaseId
+ ? { kind: "link-phase" as const, id: linkedPhaseId }
+ : null,
+ [focusQuestionId, linkedPhaseId, linkedQuestionId, linkedStepId, routeStepId, wantsChooser],
);
- const arrivalKey = arrival ? `${arrival.kind}:${arrival.id}` : "";
+ // A link carries the navigation's key as well: the hire who scrolled away and clicks the same link
+ // in the conversation again should land again.
+ const arrivalKey = arrival
+ ? `${arrival.kind}:${arrival.id}${arrival.kind.startsWith("link") ? `:${location.key}` : ""}`
+ : "";
const handledArrivalRef = useRef("");
// Read by the arrival effect, which must not re-run every time one of these is recreated.
- const startStepRef = useRef<(item: PhaseItem) => void>(() => undefined);
+ const startStepRef = useRef<(item: PhaseItem, options: StartOptions) => void>(() => undefined);
const scrollToChooserRef = useRef<() => void>(() => undefined);
+ /**
+ * Takes a handled link out of the address, so a reload or the hire's own next click decides what
+ * they are looking at rather than the link lighting the same card up again.
+ */
+ const clearLinkRef = useRef<() => void>(() => undefined);
useEffect(() => {
if (loadingState !== "success" || !path || !arrival) return;
@@ -401,14 +458,35 @@ export function OnBoardingPage() {
return;
}
+ // A link is followed in the list, so a step left open on the graph is let go of too --
+ // otherwise swiping stayed off and the graph reopened it on the way back.
+ if (arrival.kind.startsWith("link")) setGraphItemId(null);
+
+ if (arrival.kind === "link-phase") {
+ const linkedPhase = path.phases.find((phase) => phase.id === arrival.id);
+ if (linkedPhase) {
+ setSelectedPhaseId(arrival.id);
+ // An item open in the linked phase stays open: it may hold a typed answer.
+ setExpandedItemId((current) =>
+ phaseItems(linkedPhase).some((item) => item.id === current) ? current : null,
+ );
+ } else {
+ toast.error("That phase is not on your path", {
+ description: "It may have been replaced when your path was rebuilt.",
+ });
+ }
+ clearLinkRef.current();
+ return;
+ }
+
const owningPhase = path.phases.find((phase) =>
phaseItems(phase).some((item) => item.id === arrival.id),
);
if (!owningPhase) {
toast.error(
- arrival.kind === "step"
- ? "That step is not on your path"
- : "That question is not on your path",
+ arrival.kind === "question" || arrival.kind === "link-question"
+ ? "That question is not on your path"
+ : "That step is not on your path",
{ description: "It may have been replaced when your path was rebuilt." },
);
// Only the step has an address of its own to go back from; a question arrives in router
@@ -417,6 +495,16 @@ export function OnBoardingPage() {
return;
}
+ if (arrival.kind === "link-step" || arrival.kind === "link-question") {
+ setSelectedPhaseId(owningPhase.id);
+ // "You're on #3" is most likely clicked while #3 is open -- collapsing it would throw away
+ // a typed answer or skip reason.
+ setExpandedItemId((current) => (current === arrival.id ? current : null));
+ setLinkHighlight({ id: arrival.id, key: arrivalKey });
+ clearLinkRef.current();
+ return;
+ }
+
setSelectedPhaseId(owningPhase.id);
scrollToItemRef.current = arrival.id;
setExpandedItemId(arrival.id);
@@ -424,10 +512,42 @@ export function OnBoardingPage() {
// A step opened by its address is started like one opened by a click -- including the
// second link of a visit, which the load-time version could not see.
const item = phaseItems(owningPhase).find((candidate) => candidate.id === arrival.id);
- if (item) startStepRef.current(item);
+ if (item) startStepRef.current(item, { byAddress: true });
});
}, [arrival, arrivalKey, loadingState, navigate, path, toast]);
+ useEffect(() => {
+ clearLinkRef.current = () =>
+ setSearchParams(
+ (params) => {
+ params.delete("step");
+ params.delete("question");
+ params.delete("phase");
+ return params;
+ },
+ { replace: true },
+ );
+ }, [setSearchParams]);
+
+ // Scrolls to the card a link landed on, once per link. A link followed from the graph switches to
+ // the list, which `SlidingTabPanel` mounts only after the graph has slid out -- so a card that is
+ // not there yet is looked for again once it can be, the same wait `scrollToChooser` makes.
+ const scrolledLinkRef = useRef("");
+ useEffect(() => {
+ if (loadingState !== "success" || !linkHighlight) return;
+ if (scrolledLinkRef.current === linkHighlight.key) return;
+ const scroll = () => {
+ const card = document.getElementById(linkedCardId(linkHighlight.id));
+ if (!card) return false;
+ scrolledLinkRef.current = linkHighlight.key;
+ card.scrollIntoView?.({ behavior: "smooth", block: "center" });
+ return true;
+ };
+ if (scroll()) return;
+ const timer = window.setTimeout(scroll, SLIDING_PANEL_EXIT_MS);
+ return () => window.clearTimeout(timer);
+ }, [linkHighlight, loadingState, selectedPhaseId, viewMode]);
+
// Scrolls to an item the page opened on the member's behalf -- a link, "up next", "continue".
useEffect(() => {
const target = scrollToItemRef.current;
@@ -485,8 +605,12 @@ export function OnBoardingPage() {
);
/** A step the member opens for the first time is started; reopening one changes nothing. */
- const beginStepIfWaiting = async (item: PhaseItem) => {
+ const beginStepIfWaiting = async (item: PhaseItem, { byAddress = false }: StartOptions = {}) => {
if (item.kind !== "step" || item.step.status !== "WAITING" || item.step.locked) return;
+ // Arriving at `/onboarding/` for a step with a skip request open is arriving to change or
+ // withdraw it, not to begin the step; its own "Start step" button still does that. Only the
+ // address: the list's button says "Start", and a click on it should do what it says.
+ if (byAddress && isSkipPending(item.step.skip)) return;
try {
await onboardingService.startStep(item.step.id);
// The rocket marks a step *beginning*.
@@ -503,7 +627,7 @@ export function OnBoardingPage() {
// Refreshed every render, so the arrival effect can call the latest of each without taking a
// dependency on a function that is recreated on every render.
useEffect(() => {
- startStepRef.current = (item) => void beginStepIfWaiting(item);
+ startStepRef.current = (item, options) => void beginStepIfWaiting(item, options);
scrollToChooserRef.current = scrollToChooser;
});
@@ -560,6 +684,7 @@ export function OnBoardingPage() {
const selectPhase = (phaseId: string) => {
setSelectedPhaseId(phaseId);
setExpandedItemId(null);
+ setLinkHighlight(null);
};
const choosePhase = (phaseId: string) => {
@@ -666,6 +791,7 @@ export function OnBoardingPage() {
handleAnswered(item.question, result)}
continueLabel={next.label}
onContinue={next.run}
@@ -827,6 +953,16 @@ export function OnBoardingPage() {
Your project manager can rebuild your path from your page in the team area.
)}
+ {/* Generating again is the wrong hope when the corpus is what was thin -- it comes back
+ empty a second time. The conversation is the one thing here that can produce
+ something, so it is offered beside the retry (or the pointer to the PM) rather than
+ instead of it. */}
+ {generationIssues.length > 0 ? (
+ issue.title))}
+ label="Work it out with your buddy instead"
+ />
+ ) : null}
);
}
@@ -940,6 +1076,8 @@ export function OnBoardingPage() {
phase={selectedPhase}
nextItemId={nextItemId}
expandedItemId={expandedItemId}
+ linkedItemId={linkHighlight?.id ?? null}
+ onLinkHighlightEnd={clearLinkHighlight}
onToggle={toggleItem}
onPrimary={openItem}
renderExpanded={(item) => renderItemBody(item, "inline")}
@@ -1066,6 +1204,7 @@ function PhaseHeaderCard({
const progress = phaseProgress(phase);
const waitsOn = blockingPhases(phase, phases);
const unlocks = phasesUnlockedBy(phase, phases);
+ const isEmpty = phase.steps.length === 0 && phase.questions.length === 0;
return (
@@ -1079,6 +1218,17 @@ function PhaseHeaderCard({
{phase.description ? (
{phase.description}
) : null}
+ {/* The phase-level way in. A hire who does not know why a phase is here is not helped by any
+ of the buttons below it -- and an empty phase is the case the buddy exists for: its title
+ still says what it was meant to cover, and the mentor can put the result on their path. */}
+
{
return await apiClient.fetch(`${BASE_URL}/${id}/task-zero`, {
diff --git a/src/services/teamManagementService.ts b/src/services/teamManagementService.ts
index 433d8b585..02f81ad96 100644
--- a/src/services/teamManagementService.ts
+++ b/src/services/teamManagementService.ts
@@ -429,6 +429,20 @@ export async function markOnboardingFeedbackRead(feedbackId: string): Promise {
@@ -446,22 +460,20 @@ export async function getUserOnboardingPath(
const hydratedPhases = await Promise.all(
phases.map(async (phase) => {
- if (phase.steps?.length > 0) return phase;
+ // Questions cannot be hydrated the way steps can: no endpoint hands out one member's
+ // questions with their status. An empty list is the honest stand-in, and it keeps the page
+ // standing instead of taking it down.
+ const normalised = { ...phase, questions: phase.questions ?? [] };
+ if (normalised.steps?.length > 0) return normalised;
try {
const steps = await apiClient.fetch(
`/api/v1/onboarding/phases/${phase.id}/steps`,
);
- return {
- ...phase,
- steps,
- };
+ return { ...normalised, steps };
} catch {
- return {
- ...phase,
- steps: [],
- };
+ return { ...normalised, steps: [] };
}
}),
);
diff --git a/src/styles/index.css b/src/styles/index.css
index 6a5cd7989..4d3f4387c 100644
--- a/src/styles/index.css
+++ b/src/styles/index.css
@@ -1000,6 +1000,50 @@ body {
}
}
+/* The brief light on an onboarding step or question that a link from the buddy landed on.
+
+ The link opens the right phase and scrolls to the item, and on a list of look-alike cards
+ that is not quite enough to find it: this says "this one" twice and then gets out of the way.
+ Deliberately not a way of starting anything -- the hire presses the item's own button.
+
+ A ring rather than a background, so it reads over any card state (open, locked, done), and
+ delayed a beat so it plays after the smooth scroll has arrived rather than during it. */
+@keyframes app-link-highlight {
+ 0%,
+ 100% {
+ box-shadow: 0 0 0 0 transparent;
+ }
+ 25%,
+ 65% {
+ box-shadow: 0 0 0 3px var(--color-app-brand);
+ }
+ 45% {
+ box-shadow: 0 0 0 1px var(--color-app-brand-border);
+ }
+}
+
+/* `backwards`, not `both`: a last frame that outlived the animation beat the card's own shadow. */
+.app-link-highlight {
+ animation: app-link-highlight 1.6s ease-in-out 0.35s 1 backwards;
+}
+
+/* No pulse, but still marked for as long as the pulse would take: finding the item is the point,
+ not the motion. A step change rather than a fade, and gone afterwards like the pulse is. */
+@keyframes app-link-highlight-still {
+ 0% {
+ box-shadow: 0 0 0 2px var(--color-app-brand);
+ }
+ 100% {
+ box-shadow: 0 0 0 0 transparent;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .app-link-highlight {
+ animation: app-link-highlight-still 2s step-end 1 backwards;
+ }
+}
+
/* Onboarding graphs: an edge whose blocker is done and whose target is open drifts towards the
target, so "this is what you can do now" reads at a glance without another colour. */
@keyframes journey-edge-flow {
diff --git a/tests/unit/a11y/BoardPage.a11y.test.tsx b/tests/unit/a11y/BoardPage.a11y.test.tsx
index 244fb8651..c47c3fb94 100644
--- a/tests/unit/a11y/BoardPage.a11y.test.tsx
+++ b/tests/unit/a11y/BoardPage.a11y.test.tsx
@@ -32,21 +32,6 @@ const board: Board = {
boardId: "b1",
projectId: "p1",
cards: [
- {
- id: "c1",
- kind: "PATH_TO_FIRST_CONTRIBUTION",
- owner: "AI",
- position: 0,
- placedAt: null,
- content: {
- kind: "PATH_TO_FIRST_CONTRIBUTION",
- moments: [{ key: "JOINED", reachedAt: "2026-07-20T09:00:00Z" }],
- acceptedCount: 0,
- autonomyReachedAt: "2026-08-04T09:00:00Z",
- // Both lines of `BoardPathNotes` are on screen for this pass, so axe sees them.
- stalledReason: "a review has been waiting three days",
- },
- },
{
id: "c2",
kind: "OPEN_PULL_REQUESTS",
@@ -134,11 +119,8 @@ describe("BoardPage Accessibility", () => {
);
// Waits for real board content. `main` is there from first paint, so waiting for it would
- // let this pass with the grid entirely broken. The header strip of moments it used to wait
- // for is gone: that answered "how far through onboarding am I", which the board answers four
- // other ways -- only the two lines with no second home stayed, in `BoardPathNotes`.
+ // let this pass with the grid entirely broken.
await waitFor(() => expect(screen.getByText(/Add a health endpoint/)).toBeInTheDocument());
- expect(screen.getByText(/a review has been waiting three days/)).toBeInTheDocument();
expect(await axe(baseElement)).toHaveNoViolations();
});
diff --git a/tests/unit/features/board/BoardGrid.test.tsx b/tests/unit/features/board/BoardGrid.test.tsx
index 4cebe4af1..21a487537 100644
--- a/tests/unit/features/board/BoardGrid.test.tsx
+++ b/tests/unit/features/board/BoardGrid.test.tsx
@@ -8,7 +8,6 @@ import type {
BoardCard,
CurrentTaskContent,
OpenPullRequestsContent,
- PathToFirstContributionContent,
SuggestedTasksContent,
} from "../../../../src/features/board/types";
@@ -20,23 +19,6 @@ vi.mock("../../../../src/features/projects/useProjectContext", () => ({
useProjectContext: () => ({ selectedProjectId: "p1" }),
}));
-const pathContent = (
- over: Partial = {},
-): PathToFirstContributionContent => ({
- kind: "PATH_TO_FIRST_CONTRIBUTION",
- moments: [
- { key: "JOINED", reachedAt: "2026-07-20T09:00:00Z" },
- { key: "TASK_CLAIMED", reachedAt: null },
- { key: "WORK_SUBMITTED", reachedAt: null },
- { key: "FIRST_RESPONSE", reachedAt: null },
- { key: "WORK_ACCEPTED", reachedAt: null },
- ],
- acceptedCount: 0,
- autonomyReachedAt: null,
- stalledReason: null,
- ...over,
-});
-
const pullRequestContent = (
over: Partial = {},
): OpenPullRequestsContent => ({
@@ -52,7 +34,6 @@ const currentTaskContent = (over: Partial = {}): CurrentTask
title: "Fix the flaky login test",
summary: "It fails about one run in five.",
url: null,
- chosen: true,
closedAtSource: false,
...over,
});
@@ -81,41 +62,6 @@ function board(cards: BoardCard["content"][], placedAt: string | null = null): B
}
describe("BoardGrid", () => {
- it("shows an unreached moment as a dash, never as a zero", () => {
- render();
-
- // Four moments unreached, one (joined) reached.
- expect(screen.getAllByText("—")).toHaveLength(4);
- });
-
- it("says nothing has been merged yet without making it sound like a failure", () => {
- render();
-
- expect(screen.getByText(/normal early on/i)).toBeInTheDocument();
- });
-
- it("counts accepted work with the plural", () => {
- render();
-
- expect(screen.getByText("2 changes merged")).toBeInTheDocument();
- });
-
- it("tells the hire about their own stall, and points at a person", () => {
- render();
-
- expect(screen.getByText(/no response in 5 days/)).toBeInTheDocument();
- // Points at a person rather than leaving the hire with a diagnosis they cannot act on.
- expect(screen.getByText(/a person unblocks in a minute/i)).toBeInTheDocument();
- });
-
- it("dates the end of onboarding rather than scoring it", () => {
- render(
- ,
- );
-
- expect(screen.getByText(/worked unsupervised here/i)).toBeInTheDocument();
- });
-
it("flags a long wait as the review being outstanding, not the hire being slow", () => {
render(
{
it("claims the buddy added a card only when the buddy actually placed it", () => {
// The mark is an icon in the header; the sentence lives in its screen-reader text, which is
// what this asserts on — the fact is what matters, not how wide it is drawn.
- const { rerender } = render();
+ const { rerender } = render();
expect(screen.getByText("Kept up to date for you")).toBeInTheDocument();
expect(screen.queryByText("Your buddy added this card")).not.toBeInTheDocument();
- rerender();
+ rerender();
// Attribution the hire cannot check is attribution they cannot trust, so the stronger
// label is reserved for cards that carry a placement.
expect(screen.getByText("Your buddy added this card")).toBeInTheDocument();
@@ -193,9 +139,9 @@ describe("BoardGrid", () => {
it("offers to remove a card, and says the buddy will not put it back", () => {
const onDismiss = vi.fn();
- render();
+ render();
- const remove = screen.getByRole("button", { name: /remove the your path here card/i });
+ const remove = screen.getByRole("button", { name: /remove the your open pull requests card/i });
expect(remove).toHaveAttribute("title", expect.stringMatching(/won't put it back/i));
fireEvent.click(remove);
@@ -203,20 +149,17 @@ describe("BoardGrid", () => {
});
it("has no remove control when removing is not offered", () => {
- render();
+ render();
expect(screen.queryByRole("button", { name: /remove the/i })).not.toBeInTheDocument();
});
- it("separates a task the hire picked from one they were handed", () => {
- const { rerender } = render(
- ,
- );
- expect(screen.getByText("You picked this one")).toBeInTheDocument();
+ it("says the task on the card is one the hire picked", () => {
+ render();
- rerender();
- // Only one of the two is theirs to change their mind about.
- expect(screen.getByText("Handed to you as a first task")).toBeInTheDocument();
+ // Nothing hands a hire a task any more, so the card only ever holds one they claimed.
+ expect(screen.getByText("You picked this one")).toBeInTheDocument();
+ expect(screen.queryByText(/handed to you/i)).not.toBeInTheDocument();
});
it("keeps the current-task card when there is no task, and says so", () => {
diff --git a/tests/unit/features/board/BoardSubmenus.test.tsx b/tests/unit/features/board/BoardSubmenus.test.tsx
index f7c6ecffd..6b0daf07f 100644
--- a/tests/unit/features/board/BoardSubmenus.test.tsx
+++ b/tests/unit/features/board/BoardSubmenus.test.tsx
@@ -61,26 +61,6 @@ describe("taking a card into the conversation", () => {
expect(lastDraft()).toMatch(/where do I stand/i);
});
- it("a stalled path asks about the thing that is actually stuck", () => {
- render(
- ,
- );
-
- fireEvent.click(screen.getByRole("button", { name: /ask your buddy about this/i }));
-
- expect(lastDraft()).toContain("no response in 5 days");
- });
-
it("a suggested task is grabbed behind its own confirm, with the buddy one press away", () => {
render(
{
title: "Fix the flaky login test",
summary: null,
url: null,
- chosen: true,
closedAtSource: false,
},
])}
diff --git a/tests/unit/features/board/CurrentTaskCard.test.tsx b/tests/unit/features/board/CurrentTaskCard.test.tsx
index fc9492a41..c28d89bb9 100644
--- a/tests/unit/features/board/CurrentTaskCard.test.tsx
+++ b/tests/unit/features/board/CurrentTaskCard.test.tsx
@@ -33,7 +33,6 @@ function currentTaskContent(over: Partial = {}): CurrentTask
title: "Fix the flaky login test",
summary: "It fails about one run in five.",
url: null,
- chosen: true,
closedAtSource: false,
...over,
};
diff --git a/tests/unit/features/board/Marked.test.tsx b/tests/unit/features/board/Marked.test.tsx
index 44237e68c..023abedc3 100644
--- a/tests/unit/features/board/Marked.test.tsx
+++ b/tests/unit/features/board/Marked.test.tsx
@@ -89,7 +89,6 @@ describe("highlights on a card the board re-reads", () => {
title: "Ship the importer",
summary: "Roll it out behind a feature flag first.",
url: null,
- chosen: true,
closedAtSource: false,
}}
card={card}
diff --git a/tests/unit/features/board/cardAccents.test.ts b/tests/unit/features/board/cardAccents.test.ts
index 3e32eead2..b433a8790 100644
--- a/tests/unit/features/board/cardAccents.test.ts
+++ b/tests/unit/features/board/cardAccents.test.ts
@@ -6,7 +6,6 @@ import { cardAccent } from "../../../../src/features/board/layout/cardAccents";
import type { BoardCardKind } from "../../../../src/features/board/types";
const KINDS: BoardCardKind[] = [
- "PATH_TO_FIRST_CONTRIBUTION",
"CURRENT_TASK",
"DIAGRAM",
"ARRIVAL_STEPS",
diff --git a/tests/unit/features/board/pathToCards.test.ts b/tests/unit/features/board/pathToCards.test.ts
deleted file mode 100644
index f2d493c41..000000000
--- a/tests/unit/features/board/pathToCards.test.ts
+++ /dev/null
@@ -1,223 +0,0 @@
-import { describe, it, expect } from "vitest";
-import {
- markTitle,
- planCardsFromPath,
- readableTitle,
- sourceOfTitle,
- titleKey,
-} from "../../../../src/features/board/generation/pathToCards";
-import type {
- OnboardingPathEndpoint,
- OnboardingPhaseEndpoint,
- OnboardingStepEndpoint,
-} from "../../../../src/features/onboarding/types";
-
-function step(over: Partial = {}): OnboardingStepEndpoint {
- return {
- id: "step-1",
- phaseId: "phase-1",
- position: 0,
- title: "Set up your machine",
- description: "",
- type: "TASK",
- estimatedMinutes: 30,
- expectedOutcomes: [],
- tasks: [],
- resources: [],
- status: "WAITING",
- startedAt: null,
- completedAt: null,
- feedback: null,
- skip: null,
- ...over,
- };
-}
-
-function phase(over: Partial = {}): OnboardingPhaseEndpoint {
- return {
- id: "phase-1",
- pathId: "path-1",
- position: 0,
- title: "Getting set up",
- description: "",
- locked: false,
- steps: [step()],
- questions: [],
- ...over,
- };
-}
-
-function path(phases: OnboardingPhaseEndpoint[]): OnboardingPathEndpoint {
- return { id: "path-1", userId: "user-1", createdAt: "2026-01-01T00:00:00Z", phases };
-}
-
-describe("planCardsFromPath", () => {
- it("makes one card per step, with its tasks as the lines", () => {
- const plan = planCardsFromPath(
- path([
- phase({
- steps: [
- step({
- tasks: [
- {
- id: "t2",
- stepId: "step-1",
- position: 1,
- title: "Install Node",
- description: "",
- finished: false,
- },
- {
- id: "t1",
- stepId: "step-1",
- position: 0,
- title: "Clone the repo",
- description: "",
- finished: false,
- },
- ],
- }),
- ],
- }),
- ]),
- );
-
- expect(plan.cardCount).toBe(1);
- const request = plan.areas[0].cards[0].request;
- expect(request.kind).toBe("CHECKLIST");
- if (request.kind !== "CHECKLIST") throw new Error("expected a checklist");
- expect(request.items.map((item) => item.text)).toEqual(["Clone the repo", "Install Node"]);
- });
-
- it("names the area after the phase and stages it by position", () => {
- const plan = planCardsFromPath(
- path([
- phase({ id: "p1", position: 0, title: "Week one" }),
- phase({ id: "p2", position: 1, title: "Week two", steps: [step({ id: "s2" })] }),
- phase({ id: "p3", position: 2, title: "Later on", steps: [step({ id: "s3" })] }),
- ]),
- );
-
- // The stage rides on the card, not on the area: an area is where a card is filed, and when it
- // is due is a separate question that folds inside every area the same way.
- expect(plan.areas.map((area) => [area.name, area.cards[0].stage])).toEqual([
- ["Week one", "NOW"],
- ["Week two", "LATER"],
- ["Later on", "LATER"],
- ]);
- });
-
- it("chains steps inside a phase but not across phases", () => {
- const plan = planCardsFromPath(
- path([
- phase({
- id: "p1",
- steps: [step({ id: "a", position: 0 }), step({ id: "b", position: 1 })],
- }),
- phase({ id: "p2", position: 1, steps: [step({ id: "c" })] }),
- ]),
- );
-
- expect(plan.areas[0].cards.map((card) => card.afterKey)).toEqual([null, "a"]);
- // Across phases the stage carries the order. Chaining here too would leave the hire with
- // exactly one card they are allowed to open.
- expect(plan.areas[1].cards[0].afterKey).toBeNull();
- });
-
- it("leaves finished and skipped steps off the board", () => {
- const plan = planCardsFromPath(
- path([
- phase({
- steps: [
- step({ id: "done", status: "FINISHED" }),
- step({ id: "skipped", position: 1, status: "SKIPPED" }),
- step({ id: "open", position: 2 }),
- ],
- }),
- ]),
- );
-
- expect(plan.cardCount).toBe(1);
- expect(plan.areas[0].cards[0].key).toBe("open");
- });
-
- it("falls back to expected outcomes, then to the step's own title", () => {
- const plan = planCardsFromPath(
- path([
- phase({
- steps: [
- step({ id: "outcomes", expectedOutcomes: ["The project builds locally"] }),
- step({ id: "bare", position: 1, title: "Read the architecture doc" }),
- ],
- }),
- ]),
- );
-
- const lines = plan.areas[0].cards.map((card) =>
- card.request.kind === "CHECKLIST" ? card.request.items.map((item) => item.text) : [],
- );
- expect(lines).toEqual([["The project builds locally"], ["Read the architecture doc"]]);
- });
-
- it("produces no area for a phase with nothing left to do", () => {
- const plan = planCardsFromPath(path([phase({ steps: [step({ status: "FINISHED" })] })]));
-
- expect(plan.areas).toEqual([]);
- });
-});
-
-describe("card source markers", () => {
- it("round-trips a title through a marker without changing what a person reads", () => {
- const stored = markTitle("TEAM", "Read the incident write-up");
-
- expect(sourceOfTitle(stored)).toBe("TEAM");
- expect(readableTitle(stored)).toBe("Read the incident write-up");
- });
-
- it("tells the two generated sources apart", () => {
- expect(sourceOfTitle(markTitle("PATH", "Set up your machine"))).toBe("PATH");
- expect(sourceOfTitle(markTitle("TEAM", "Set up your machine"))).toBe("TEAM");
- });
-
- it("reports a hand-written title as coming from nobody", () => {
- expect(sourceOfTitle("Groceries")).toBeNull();
- expect(sourceOfTitle(null)).toBeNull();
- expect(readableTitle("Groceries")).toBe("Groceries");
- });
-
- it("marks path cards as coming from the path", () => {
- const plan = planCardsFromPath(path([phase()]));
- const request = plan.areas[0].cards[0].request;
- if (request.kind !== "CHECKLIST") throw new Error("expected a checklist");
-
- expect(sourceOfTitle(request.title ?? null)).toBe("PATH");
- expect(readableTitle(request.title ?? "")).toBe("Set up your machine");
- });
-});
-
-describe("recognising a card that is already there", () => {
- it("stores a title the way the server will, so a second run finds it", () => {
- // The server trims what it stores. A title planned with the space still on it would never
- // match the card it just wrote, and every run would add another copy.
- expect(markTitle("TEAM", "Read the runbook ")).toBe(markTitle("TEAM", "Read the runbook"));
- });
-
- it("matches a stored title against the one that was planned", () => {
- expect(titleKey(markTitle("TEAM", "Read the runbook"))).toBe(titleKey("Read the runbook"));
- });
-
- it("ignores the differences nobody can see", () => {
- expect(titleKey(" Read the runbook ")).toBe(titleKey("read the runbook"));
- });
-
- it("treats the same work from the path and from the team as one card", () => {
- expect(titleKey(markTitle("PATH", "Read the runbook"))).toBe(
- titleKey(markTitle("TEAM", "Read the runbook")),
- );
- });
-
- it("keeps two differently named cards apart", () => {
- expect(titleKey("Read the runbook")).not.toBe(titleKey("Read the handbook"));
- expect(titleKey(null)).toBe("");
- });
-});
diff --git a/tests/unit/features/board/useBoard.test.tsx b/tests/unit/features/board/useBoard.test.tsx
index f316f061a..696673b6d 100644
--- a/tests/unit/features/board/useBoard.test.tsx
+++ b/tests/unit/features/board/useBoard.test.tsx
@@ -29,17 +29,11 @@ const board = (cardIds: string[]): Board => ({
projectId: "p1",
cards: cardIds.map((id, index) => ({
id,
- kind: "PATH_TO_FIRST_CONTRIBUTION",
+ kind: "OPEN_PULL_REQUESTS",
owner: "AI",
position: index,
placedAt: null,
- content: {
- kind: "PATH_TO_FIRST_CONTRIBUTION",
- moments: [],
- acceptedCount: 0,
- autonomyReachedAt: null,
- stalledReason: null,
- },
+ content: { kind: "OPEN_PULL_REQUESTS", pullRequests: [], attributionMissing: false },
})),
});
@@ -132,7 +126,7 @@ describe("useBoard", () => {
expect(accepted).toBe(false);
expect(result.current.writeError).toBe(true);
- expect(result.current.board?.cards[0].content.kind).toBe("PATH_TO_FIRST_CONTRIBUTION");
+ expect(result.current.board?.cards[0].content.kind).toBe("OPEN_PULL_REQUESTS");
});
it("keeps the card and surfaces the failure when removal does not go through", async () => {
diff --git a/tests/unit/features/buddy/BuddyActionProposals.test.tsx b/tests/unit/features/buddy/BuddyActionProposals.test.tsx
index f67ad6670..50793a03c 100644
--- a/tests/unit/features/buddy/BuddyActionProposals.test.tsx
+++ b/tests/unit/features/buddy/BuddyActionProposals.test.tsx
@@ -12,8 +12,8 @@ vi.mock("../../../../src/features/buddy/components/BuddyOrientationCard", () =>
function action(overrides: Partial = {}): HireActionProposal {
return {
id: "a1",
- action: "claim_task_zero",
- label: "Start Task 0",
+ action: "claim_goal",
+ label: "Work toward this task",
status: "idle",
...overrides,
};
@@ -34,7 +34,7 @@ describe("BuddyActionProposals", () => {
// Rendering the offer must not fire the action.
expect(onConfirm).not.toHaveBeenCalled();
- await userEvent.click(screen.getByRole("button", { name: /Start Task 0/ }));
+ await userEvent.click(screen.getByRole("button", { name: /Work toward this task/ }));
expect(onConfirm).toHaveBeenCalledWith("m1", expect.objectContaining({ id: "a1" }));
});
@@ -61,14 +61,16 @@ describe("BuddyActionProposals", () => {
render(
,
);
- expect(screen.getByText("Task 0 is yours.")).toBeInTheDocument();
- expect(screen.queryByRole("button", { name: /Start Task 0/ })).not.toBeInTheDocument();
+ expect(screen.getByText("You are now working toward it.")).toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: /Work toward this task/ })).not.toBeInTheDocument();
});
it("offers a retry on a transport error", () => {
@@ -83,7 +85,7 @@ describe("BuddyActionProposals", () => {
expect(screen.getByText(/try again/i)).toBeInTheDocument();
// The confirm button is still there to retry.
- expect(screen.getByRole("button", { name: /Start Task 0/ })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: /Work toward this task/ })).toBeInTheDocument();
});
it("renders the orientation packet in the thread once open_orientation resolves", () => {
@@ -112,7 +114,9 @@ describe("BuddyActionProposals", () => {
const { rerender } = render(
,
@@ -140,6 +144,64 @@ describe("BuddyActionProposals", () => {
expect(screen.queryByTestId("buddy-orientation-card")).not.toBeInTheDocument();
});
+ it("shows the question a flag will send, not just the button", () => {
+ // The button only says that something will be flagged. What lands in the PM's inbox is
+ // the question the buddy composed, and the hire sends it in their name.
+ render(
+ ,
+ );
+
+ expect(
+ screen.getByText("Sends to your PM: “Who owns the staging database credentials?”"),
+ ).toBeInTheDocument();
+ });
+
+ it("shows no message line for an action that sends nobody anything", () => {
+ render(
+ ,
+ );
+
+ expect(screen.queryByText(/Sends to your PM/)).not.toBeInTheDocument();
+ });
+
+ it("shows the whole skip reason before the hire sends it in their name", () => {
+ render(
+ ,
+ );
+
+ expect(
+ screen.getByText("Your reason: “I already have VPN access from my last team.”"),
+ ).toBeInTheDocument();
+ });
+
/**
* The one action whose confirm writes the mentor's own sentences onto the hire's board. Which is
* why the lines are on the offer: "Keep this" over words somebody else wrote is not something to
diff --git a/tests/unit/features/buddy/BuddyComposer.test.tsx b/tests/unit/features/buddy/BuddyComposer.test.tsx
new file mode 100644
index 000000000..ef862642e
--- /dev/null
+++ b/tests/unit/features/buddy/BuddyComposer.test.tsx
@@ -0,0 +1,52 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { useState } from "react";
+import { describe, expect, it, vi } from "vitest";
+import { BuddyComposer } from "../../../../src/features/buddy/components/BuddyComposer";
+import { BuddyDraftContext } from "../../../../src/features/buddy/buddyDraftContext";
+
+/**
+ * A question handed to the composer from outside has to be sendable with Enter.
+ *
+ * A suggestion chip (or "Ask your buddy about this step" with the dock already open) sets the draft
+ * without touching the box, and focus stayed on the chip — so Enter clicked the chip again and
+ * nothing was sent.
+ */
+describe("BuddyComposer", () => {
+ function Harness({ onSend }: { onSend: (text: string) => void }) {
+ const [draft, setDraft] = useState("");
+ return (
+ <>
+
+ {
+ event.preventDefault();
+ onSend(draft);
+ setDraft("");
+ return true;
+ },
+ }}
+ >
+
+
+ >
+ );
+ }
+
+ it("takes the caret when a chip fills it, so Enter sends the question", async () => {
+ const user = userEvent.setup();
+ const onSend = vi.fn();
+ render();
+
+ await user.click(screen.getByRole("button", { name: "chip" }));
+
+ expect(screen.getByRole("textbox", { name: "Message" })).toHaveFocus();
+ await user.keyboard("{Enter}");
+ expect(onSend).toHaveBeenCalledWith("Where am I on my path?");
+ });
+});
diff --git a/tests/unit/features/buddy/BuddyMarkdown.test.tsx b/tests/unit/features/buddy/BuddyMarkdown.test.tsx
new file mode 100644
index 000000000..30f7d94ff
--- /dev/null
+++ b/tests/unit/features/buddy/BuddyMarkdown.test.tsx
@@ -0,0 +1,35 @@
+import { render, screen } from "@testing-library/react";
+import { MemoryRouter } from "react-router-dom";
+import { describe, expect, it } from "vitest";
+import { BuddyMarkdown } from "../../../../src/features/buddy/components/BuddyMarkdown";
+
+function renderLink(href: string) {
+ render(
+
+
+ ,
+ );
+ return screen.getByRole("link", { name: "the link" });
+}
+
+describe("BuddyMarkdown links", () => {
+ it("navigates in place for an app path", () => {
+ const link = renderLink("/onboarding?step=s1");
+
+ expect(link).toHaveAttribute("href", "/onboarding?step=s1");
+ expect(link).not.toHaveAttribute("target");
+ });
+
+ it.each(["//evil.example", "https://example.com"])(
+ "treats %s as somebody else's site",
+ (href) => {
+ expect(renderLink(href)).toHaveAttribute("target", "_blank");
+ },
+ );
+
+ it("never hands a backslash path on as one that could leave the app", () => {
+ // Browsers read `/\host` as `//host`. Markdown drops the backslash before it reaches the link,
+ // and `isInAppPath` would refuse it if it ever did not.
+ expect(renderLink("/\\evil.example").getAttribute("href")).not.toMatch(/^\/[\\/]/);
+ });
+});
diff --git a/tests/unit/features/buddy/useBuddyPathSync.test.tsx b/tests/unit/features/buddy/useBuddyPathSync.test.tsx
new file mode 100644
index 000000000..c07378d19
--- /dev/null
+++ b/tests/unit/features/buddy/useBuddyPathSync.test.tsx
@@ -0,0 +1,28 @@
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { act, renderHook } from "@testing-library/react";
+import type { ReactNode } from "react";
+import { describe, expect, it, vi } from "vitest";
+import { announceBuddyPathChanged } from "../../../../src/features/buddy/aiBuddyBus";
+import { useBuddyPathSync } from "../../../../src/features/buddy/hooks/useBuddyPathSync";
+import { queryKeys } from "../../../../src/services/queryKeys";
+
+describe("useBuddyPathSync", () => {
+ it("marks the board and the onboarding status stale when the buddy changed the path", () => {
+ const client = new QueryClient();
+ const invalidate = vi.spyOn(client, "invalidateQueries");
+ const wrapper = ({ children }: { children: ReactNode }) => (
+ {children}
+ );
+ const { unmount } = renderHook(() => useBuddyPathSync(), { wrapper });
+
+ act(() => announceBuddyPathChanged());
+
+ expect(invalidate).toHaveBeenCalledWith({ queryKey: queryKeys.onboarding.myStatuses() });
+ expect(invalidate).toHaveBeenCalledWith({ queryKey: queryKeys.board.all() });
+
+ unmount();
+ invalidate.mockClear();
+ act(() => announceBuddyPathChanged());
+ expect(invalidate).not.toHaveBeenCalled();
+ });
+});
diff --git a/tests/unit/features/onboarding/buddyDrafts.test.ts b/tests/unit/features/onboarding/buddyDrafts.test.ts
new file mode 100644
index 000000000..e97a5b318
--- /dev/null
+++ b/tests/unit/features/onboarding/buddyDrafts.test.ts
@@ -0,0 +1,128 @@
+import { describe, expect, it } from "vitest";
+import {
+ askAboutEmptyPhase,
+ askAboutEmptyPhases,
+ askAboutPhase,
+ askAboutQuestion,
+ askAboutStep,
+ askAboutWrongAnswer,
+} from "../../../../src/features/onboarding/buddyDrafts";
+import type {
+ OnboardingPhaseEndpoint,
+ OnboardingQuestionEndpoint,
+ OnboardingStepEndpoint,
+} from "../../../../src/features/onboarding/types";
+
+/**
+ * The opening sentence of a conversation about something on the path.
+ *
+ * Two things are worth pinning. It is written in the hire's voice, because the draft lands in their
+ * composer and they send it — a sentence that reads like an instruction from the page is one they
+ * stop trusting as their own. And somebody else's title is quoted, not pasted: a path step whose
+ * title runs to three lines would otherwise arrive as a composer full of it.
+ */
+describe("buddy drafts", () => {
+ const phase = (over: Partial = {}) =>
+ ({
+ id: "p1",
+ pathId: "path",
+ position: 0,
+ title: "Environment Setup",
+ description: "",
+ locked: false,
+ steps: [],
+ questions: [],
+ ...over,
+ }) satisfies OnboardingPhaseEndpoint;
+
+ const step = (over: Partial = {}) =>
+ ({
+ id: "s1",
+ phaseId: "p1",
+ position: 0,
+ title: "Clone the repository",
+ description: "",
+ type: "TASK",
+ estimatedMinutes: 20,
+ expectedOutcomes: [],
+ tasks: [],
+ resources: [],
+ status: "WAITING",
+ startedAt: null,
+ completedAt: null,
+ feedback: null,
+ skip: null,
+ ...over,
+ }) satisfies OnboardingStepEndpoint;
+
+ const question = (over: Partial = {}) =>
+ ({
+ id: "q1",
+ phaseId: "p1",
+ position: 0,
+ type: "MULTIPLE_CHOICE",
+ question: "Which meeting sets the sprint scope?",
+ status: "OPEN",
+ ...over,
+ }) satisfies OnboardingQuestionEndpoint;
+
+ it("asks in the hire's own voice, not the page's", () => {
+ for (const draft of [
+ askAboutPhase(phase()),
+ askAboutStep(step()),
+ askAboutQuestion(question(), "Meetings"),
+ askAboutWrongAnswer(question(), "Meetings"),
+ askAboutEmptyPhase("Deployment"),
+ ]) {
+ expect(draft).toMatch(/^(I|The|We)\b/);
+ expect(draft).toContain("?");
+ }
+ });
+
+ it("names the thing it is about, so the mentor does not have to ask", () => {
+ expect(askAboutPhase(phase())).toContain("Environment Setup");
+ expect(askAboutStep(step())).toContain("Clone the repository");
+ expect(askAboutQuestion(question(), "Meetings")).toContain(
+ "Which meeting sets the sprint scope?",
+ );
+ expect(askAboutQuestion(question(), "Meetings")).toContain("Meetings");
+ });
+
+ it("names every phase that came back empty, not only the first", () => {
+ expect(askAboutEmptyPhases(["Deployment"])).toBe(askAboutEmptyPhase("Deployment"));
+ const draft = askAboutEmptyPhases(["Deployment", "Testing", "Release"]);
+ expect(draft).toContain('The "Deployment", "Testing" and "Release" phases');
+ expect(draft).toContain("?");
+ });
+
+ it("says the hire wants to understand the question, not be handed the answer", () => {
+ // The mentor is not given the correct answer and will say so if asked. Opening this way is the
+ // cheapest way for a hire's first experience of the feature not to be a refusal.
+ expect(askAboutQuestion(question(), "Meetings")).toContain("rather work the answer out");
+ expect(askAboutWrongAnswer(question(), "Meetings")).toContain("understand it");
+ });
+
+ it("says an empty phase generated nothing, which is the fact the mentor needs", () => {
+ const draft = askAboutEmptyPhase("Deployment");
+
+ expect(draft).toContain("Deployment");
+ expect(draft).toContain("empty");
+ // It asks to work out what the phase should contain -- which is what `add_path_step` is for.
+ expect(draft).toContain("what it should contain");
+ });
+
+ it("quotes a long title rather than pasting it", () => {
+ const long = "Read ".repeat(60).trim();
+
+ const draft = askAboutStep(step({ title: long }));
+
+ expect(draft).toContain("…");
+ expect(draft.length).toBeLessThan(long.length);
+ });
+
+ it("leaves a short title exactly as it was written", () => {
+ expect(askAboutStep(step({ title: "Clone the repository" }))).toContain(
+ '"Clone the repository"',
+ );
+ });
+});
diff --git a/tests/unit/features/onboarding/components/StepOriginBadge.test.tsx b/tests/unit/features/onboarding/components/StepOriginBadge.test.tsx
new file mode 100644
index 000000000..46ec7e9be
--- /dev/null
+++ b/tests/unit/features/onboarding/components/StepOriginBadge.test.tsx
@@ -0,0 +1,62 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+import { StepOriginBadge } from "../../../../../src/features/onboarding/components/StepOriginBadge";
+import type { OnboardingStepEndpoint } from "../../../../../src/features/onboarding/types";
+
+/**
+ * Who put a step on the path, said on the card.
+ *
+ * The badge used to read this off `isAiAssisted`, which has two values and three answers to give:
+ * everything not AI-generated was "Custom step by PM". So a step the hire wrote, and later a step
+ * their buddy proposed, both claimed their team required it — and a hire who cannot tell what the
+ * team requires from what they agreed to in a chat has lost the distinction the badge is for.
+ */
+describe("StepOriginBadge", () => {
+ const step = (over: Partial) => ({ id: "s1", ...over }) as never;
+
+ it("says the buddy added it, not a PM", () => {
+ render();
+
+ expect(screen.getByText("Added with your buddy")).toBeInTheDocument();
+ expect(screen.queryByText("Custom step by PM")).not.toBeInTheDocument();
+ });
+
+ it("says the hire added it themselves", () => {
+ render();
+
+ expect(screen.getByText("You added this")).toBeInTheDocument();
+ });
+
+ it("names the hire, not 'you', for a PM reviewing somebody else's path", () => {
+ // The same badge sits on the team page, where "You added this" would read as the PM's own step.
+ render();
+ expect(screen.getByText("Added by the hire")).toBeInTheDocument();
+ expect(screen.queryByText("You added this")).not.toBeInTheDocument();
+ });
+
+ it("says the buddy without 'your' in the reviewer view", () => {
+ render();
+
+ expect(screen.getByText("Added with the buddy")).toBeInTheDocument();
+ });
+
+ it("keeps the PM badge for what a PM prescribed", () => {
+ render();
+
+ expect(screen.getByText("Custom step by PM")).toBeInTheDocument();
+ });
+
+ it("wears nothing on a generated step, which is most of the path", () => {
+ const { container } = render();
+
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it("still reads a pre-origin row off isAiAssisted", () => {
+ // Rows written before the column default to GENERATED in the database, while `isAiAssisted`
+ // still records that a person authored them. Their badge is the one they always had.
+ render();
+
+ expect(screen.getByText("Custom step by PM")).toBeInTheDocument();
+ });
+});
diff --git a/tests/unit/features/onboarding/components/journey/QuestionWorkspace.test.tsx b/tests/unit/features/onboarding/components/journey/QuestionWorkspace.test.tsx
new file mode 100644
index 000000000..26e08bbab
--- /dev/null
+++ b/tests/unit/features/onboarding/components/journey/QuestionWorkspace.test.tsx
@@ -0,0 +1,91 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { QuestionWorkspace } from "../../../../../../src/features/onboarding/components/journey/QuestionWorkspace";
+import type { OnboardingQuestionEndpoint } from "../../../../../../src/features/onboarding/types";
+
+const mockOpenAiBuddy = vi.hoisted(() => vi.fn());
+
+vi.mock("../../../../../../src/features/buddy/aiBuddyBus", () => ({
+ openAiBuddy: mockOpenAiBuddy,
+}));
+
+vi.mock("../../../../../../src/services/onboardingService", () => ({
+ onboardingService: { submitQuestionAttempt: vi.fn() },
+}));
+
+import { onboardingService } from "../../../../../../src/services/onboardingService";
+
+const question: OnboardingQuestionEndpoint = {
+ id: "q1",
+ phaseId: "phase1",
+ position: 1,
+ type: "SHORT_TEXT",
+ question: "Who runs the retro?",
+ status: "OPEN",
+};
+
+function renderWorkspace(over: Partial = {}) {
+ render(
+ ,
+ );
+}
+
+/** The draft the last "ask the buddy" control put in the composer. */
+function lastDraft(): string {
+ const calls = mockOpenAiBuddy.mock.calls as [{ draft: string }][];
+ return calls[calls.length - 1][0].draft;
+}
+
+describe("QuestionWorkspace: the buddy", () => {
+ beforeEach(() => vi.clearAllMocks());
+
+ it("is offered before an attempt, asking for the material rather than the answer", async () => {
+ const user = userEvent.setup();
+ renderWorkspace();
+
+ await user.click(
+ screen.getByRole("button", { name: "Not sure? Ask your buddy to explain the material" }),
+ );
+
+ expect(lastDraft()).toContain("Who runs the retro?");
+ expect(lastDraft()).toContain("Meetings");
+ expect(lastDraft()).toContain("rather work the answer out");
+ });
+
+ it("speaks up louder on a question already answered wrong", () => {
+ renderWorkspace({ status: "RETRY" });
+
+ expect(screen.getByRole("button", { name: "Go through this with your buddy" })).toBeVisible();
+ });
+
+ it("offers to go through the material right after a wrong answer", async () => {
+ vi.mocked(onboardingService.submitQuestionAttempt).mockResolvedValue({
+ attemptId: "a1",
+ questionId: "q1",
+ correct: false,
+ createdAt: "2026-09-24T10:00:00Z",
+ correctOptionIds: [],
+ correctAnswer: null,
+ explanation: null,
+ feedback: null,
+ status: "RETRY",
+ onboardingCompleted: false,
+ });
+ const user = userEvent.setup();
+ renderWorkspace();
+
+ await user.type(screen.getByRole("textbox"), "The PM");
+ await user.click(screen.getByRole("button", { name: "Submit answer" }));
+ await user.click(await screen.findByRole("button", { name: "Go through it with your buddy" }));
+
+ expect(lastDraft()).toContain("wrong");
+ expect(lastDraft()).toContain("Who runs the retro?");
+ });
+});
diff --git a/tests/unit/features/onboarding/components/journey/StepWorkspace.test.tsx b/tests/unit/features/onboarding/components/journey/StepWorkspace.test.tsx
index 85565c79d..4111d7491 100644
--- a/tests/unit/features/onboarding/components/journey/StepWorkspace.test.tsx
+++ b/tests/unit/features/onboarding/components/journey/StepWorkspace.test.tsx
@@ -1,4 +1,4 @@
-import { render, screen, waitFor } from "@testing-library/react";
+import { act, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { StepWorkspace } from "../../../../../../src/features/onboarding/components/journey/StepWorkspace";
@@ -22,7 +22,16 @@ vi.mock("../../../../../../src/services/onboardingService", () => ({
},
}));
+const mockOpenAiBuddy = vi.hoisted(() => vi.fn());
+
+// The real path-changed signal, so a test can announce one the way the dock does.
+vi.mock("../../../../../../src/features/buddy/aiBuddyBus", async (importOriginal) => ({
+ ...(await importOriginal()),
+ openAiBuddy: mockOpenAiBuddy,
+}));
+
import { onboardingService } from "../../../../../../src/services/onboardingService";
+import { announceBuddyPathChanged } from "../../../../../../src/features/buddy/aiBuddyBus";
const step = {
id: "step1",
@@ -231,6 +240,64 @@ describe("StepWorkspace", () => {
expect(mockFlyby).toHaveBeenCalled();
});
+ it("offers the buddy on a step that is still open, with the step in the draft", async () => {
+ const user = userEvent.setup();
+ renderWorkspace();
+
+ await user.click(
+ await screen.findByRole("button", { name: "Stuck? Ask your buddy about this step" }),
+ );
+
+ expect(mockOpenAiBuddy).toHaveBeenCalledWith({
+ draft: expect.stringContaining("Setup Environment") as string,
+ });
+ });
+
+ it("re-reads the step after the buddy changed the path", async () => {
+ renderWorkspace();
+ await screen.findByText("1/2 done");
+ vi.mocked(onboardingService.fetchTasks).mockResolvedValue(
+ tasks.map((task) => ({ ...task, finished: true })),
+ );
+
+ act(() => announceBuddyPathChanged());
+
+ await waitFor(() => expect(screen.getByText("2/2 done")).toBeInTheDocument());
+ });
+
+ it("keeps a half-written skip reason when the buddy changes the path", async () => {
+ const user = userEvent.setup();
+ renderWorkspace();
+
+ await user.click(await screen.findByRole("button", { name: "Skip" }));
+ await user.type(screen.getByLabelText("Reason for skipping"), "Already know it");
+ act(() => announceBuddyPathChanged());
+
+ await waitFor(() => expect(onboardingService.fetchStep).toHaveBeenCalledTimes(2));
+ expect(screen.getByLabelText("Reason for skipping")).toHaveValue("Already know it");
+ });
+
+ it("does not read the step again when the path catches up with a buddy change", async () => {
+ const { rerender, props } = renderWaitingWorkspace({ status: "WAITING" });
+ await screen.findByText("1/2 done");
+ vi.mocked(onboardingService.fetchStep).mockResolvedValue({ ...step, status: "FINISHED" });
+
+ act(() => announceBuddyPathChanged());
+ await waitFor(() => expect(onboardingService.fetchStep).toHaveBeenCalledTimes(2));
+ rerender();
+
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ expect(onboardingService.fetchStep).toHaveBeenCalledTimes(2);
+ });
+
+ it("does not offer the buddy on a step that is behind the hire", async () => {
+ vi.mocked(onboardingService.fetchStep).mockResolvedValue({ ...step, status: "FINISHED" });
+ renderWorkspace();
+
+ await screen.findByText("Set up your dev environment");
+ expect(screen.queryByRole("button", { name: /ask your buddy/i })).not.toBeInTheDocument();
+ });
+
it("sends a skip request with a reason", async () => {
vi.mocked(onboardingService.skipStep).mockResolvedValue({
id: "skip1",
diff --git a/tests/unit/features/onboarding/itemNumbers.test.ts b/tests/unit/features/onboarding/itemNumbers.test.ts
new file mode 100644
index 000000000..53411a4e6
--- /dev/null
+++ b/tests/unit/features/onboarding/itemNumbers.test.ts
@@ -0,0 +1,63 @@
+import { describe, expect, it } from "vitest";
+import { itemNumbers } from "../../../../src/features/onboarding/itemNumbers";
+import type { OnboardingPhaseEndpoint } from "../../../../src/features/onboarding/types";
+
+/**
+ * The number an item is shown with.
+ *
+ * It exists so a hire can say "let's do 3" instead of retyping a title — which only works while this
+ * agrees with `BuddyPathTools`, where the same rule is written in Kotlin. Steps first in position
+ * order, then questions.
+ */
+describe("itemNumbers", () => {
+ function phase(over: Partial = {}): OnboardingPhaseEndpoint {
+ return {
+ id: "p1",
+ pathId: "path",
+ position: 0,
+ title: "Setup",
+ description: "",
+ locked: false,
+ steps: [],
+ questions: [],
+ ...over,
+ };
+ }
+
+ const step = (id: string, position: number) => ({ id, position }) as never;
+ const question = (id: string, position: number) => ({ id, position }) as never;
+
+ it("numbers the steps first, then the questions", () => {
+ const numbers = itemNumbers(
+ phase({
+ steps: [step("s1", 0), step("s2", 1)],
+ questions: [question("q1", 0)],
+ }),
+ );
+
+ expect(numbers.get("s1")).toBe(1);
+ expect(numbers.get("s2")).toBe(2);
+ // The question continues the same sequence rather than starting a second one, because the page
+ // shows one list of items and a hire counts down what they see.
+ expect(numbers.get("q1")).toBe(3);
+ });
+
+ it("goes by position, not by the order the payload happened to arrive in", () => {
+ const numbers = itemNumbers(phase({ steps: [step("late", 5), step("early", 1)] }));
+
+ expect(numbers.get("early")).toBe(1);
+ expect(numbers.get("late")).toBe(2);
+ });
+
+ it("gives steps and questions distinct numbers even when their positions collide", () => {
+ // They carry their own positions underneath, so a shared one is ordinary. A hire counting down
+ // one visible list must still get one number per item.
+ const numbers = itemNumbers(phase({ steps: [step("s1", 0)], questions: [question("q1", 0)] }));
+
+ expect([...numbers.values()]).toEqual([1, 2]);
+ });
+
+ it("is empty for a phase with nothing in it", () => {
+ expect(itemNumbers(phase()).size).toBe(0);
+ });
+});
diff --git a/tests/unit/features/team-management/components/detail/MemberJourneySection.test.tsx b/tests/unit/features/team-management/components/detail/MemberJourneySection.test.tsx
index cd75f4f45..fc1f9f3de 100644
--- a/tests/unit/features/team-management/components/detail/MemberJourneySection.test.tsx
+++ b/tests/unit/features/team-management/components/detail/MemberJourneySection.test.tsx
@@ -186,6 +186,27 @@ describe("MemberJourneySection", () => {
expect(request).toMatchObject({ waitsOn: ["verify"], unlocks: [] });
});
+ /** The PM reads this, so "you" in the badge would be about the PM. */
+ it("names the hire, not the reader, on a step the hire added", () => {
+ const withHireStep: OnboardingPathEndpoint = {
+ ...path,
+ phases: [
+ {
+ ...path.phases[0],
+ steps: [
+ ...path.phases[0].steps,
+ step({ id: "own", position: 3, title: "My own step", origin: "HIRE" }),
+ ],
+ },
+ path.phases[1],
+ ],
+ };
+ renderSection({ path: withHireStep });
+
+ expect(screen.getByText("Added by the hire")).toBeInTheDocument();
+ expect(screen.queryByText("You added this")).not.toBeInTheDocument();
+ });
+
it("shows a phase asked for from the summary above, and scrolls down to it", () => {
const scrollIntoView = vi.spyOn(HTMLElement.prototype, "scrollIntoView");
const ref = createRef();
diff --git a/tests/unit/pages/OnBoardingPage.test.tsx b/tests/unit/pages/OnBoardingPage.test.tsx
index 40e90839a..d0daf8fd4 100644
--- a/tests/unit/pages/OnBoardingPage.test.tsx
+++ b/tests/unit/pages/OnBoardingPage.test.tsx
@@ -1,4 +1,4 @@
-import { render, screen, waitFor, within } from "@testing-library/react";
+import { act, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { Link, MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
@@ -6,6 +6,7 @@ import { OnBoardingPage } from "../../../src/pages/OnBoardingPage";
import { http, HttpResponse } from "msw";
import { server } from "../../unit/setup/vitest.setup";
import { onboardingService } from "../../../src/services/onboardingService";
+import { announceBuddyPathChanged } from "../../../src/features/buddy/aiBuddyBus";
import {
OnboardingJourneyContext,
type OnboardingJourneyValue,
@@ -28,6 +29,18 @@ vi.mock("../../../src/context/useAuth", () => ({
useAuth: () => ({ profile: { id: signedInUserId.value } }),
}));
+// One stable object, as the real hook's is: the page keeps `toast` in effect dependencies.
+const toastMocks = vi.hoisted(() => ({
+ show: vi.fn(),
+ info: vi.fn(),
+ success: vi.fn(),
+ warning: vi.fn(),
+ error: vi.fn(),
+ dismiss: vi.fn(),
+ dismissAll: vi.fn(),
+}));
+vi.mock("../../../src/context/useToast", () => ({ useToast: () => toastMocks }));
+
// The celebratory layer is decorative and lives behind its own provider; the
// page only needs a no-op `celebrate` to render.
vi.mock("../../../src/features/moments", () => ({
@@ -738,6 +751,53 @@ describe("OnBoardingPage", () => {
expect(await screen.findByRole("button", { name: "Mark as complete" })).toBeInTheDocument();
});
+ /**
+ * The buddy links a step with a pending skip here so the hire can change or withdraw the reason.
+ * Opening it for that is not beginning it.
+ */
+ it("opens a step waiting on a skip decision by its address without starting it", async () => {
+ const waiting = {
+ ...phaseFixture("phase1", 1, "Phase 1").steps[0],
+ status: "WAITING",
+ skip: {
+ id: "skip1",
+ stepId: "step-phase1",
+ reason: "I did this on my last team.",
+ accepted: null,
+ reviewComment: null,
+ reviewedAt: null,
+ },
+ };
+ server.use(
+ http.get("/api/v1/onboarding/me/steps/:stepId", () => HttpResponse.json(waiting)),
+ http.get("/api/v1/onboarding/me/steps/:stepId/tasks", () => HttpResponse.json([])),
+ http.get("/api/v1/onboarding/me/steps/:stepId/resources", () => HttpResponse.json([])),
+ http.get("/api/v1/onboarding/me/path", () =>
+ HttpResponse.json({
+ id: "path1",
+ userId: "user1",
+ createdAt: new Date().toISOString(),
+ phases: [{ ...phaseFixture("phase1", 1, "Phase 1"), steps: [waiting] }],
+ }),
+ ),
+ );
+ const startStep = vi.spyOn(onboardingService, "startStep");
+
+ render(
+
+
+
+ } />
+
+
+ ,
+ );
+
+ expect(await screen.findByText("Skip requested")).toBeInTheDocument();
+ await expectUnfolded("step-phase1");
+ expect(startStep).not.toHaveBeenCalled();
+ });
+
it("names what a locked item is waiting on", async () => {
server.use(
http.get("/api/v1/onboarding/me/path", () =>
@@ -894,3 +954,199 @@ describe("OnBoardingPage", () => {
await waitFor(() => expect(screen.getByTestId("location")).toHaveTextContent(/^\/onboarding$/));
});
});
+
+/**
+ * Following a link the buddy wrote.
+ *
+ * The mentor is handed each item's link so that "you are on #3" can be clickable: `?step=`,
+ * `?question=` or `?phase=`, which also means the link survives being copied, kept or
+ * opened in a second tab. A step or question link *lands* on the card — its phase opens, the page
+ * scrolls to it, and it lights up — and starts nothing: that stays the hire's own click.
+ */
+describe("OnBoardingPage: changes the buddy made", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ projectContextState.selectedProjectId = "proj1";
+ });
+
+ it("re-reads the path when the buddy announces a change", async () => {
+ const fetchPath = vi.spyOn(onboardingService, "fetchPath");
+ server.use(
+ http.get("/api/v1/onboarding/me/path", () =>
+ HttpResponse.json({
+ id: "path1",
+ userId: "user1",
+ createdAt: new Date().toISOString(),
+ generationIssues: [],
+ phases: [phaseFixture("phase-1", 0, "Overview")],
+ }),
+ ),
+ );
+
+ render(
+
+
+ ,
+ );
+ await screen.findByRole("heading", { name: "Overview", level: 2 });
+ const before = fetchPath.mock.calls.length;
+
+ act(() => announceBuddyPathChanged());
+
+ await waitFor(() => expect(fetchPath.mock.calls.length).toBeGreaterThan(before));
+ });
+});
+
+describe("OnBoardingPage: links from the buddy", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ projectContextState.selectedProjectId = "proj1";
+ });
+
+ function pathWithQuestion(status: "OPEN" | "LOCKED" | "PASSED") {
+ const phase = phaseFixture("phase-2", 1, "Meetings");
+ return {
+ id: "path1",
+ userId: "user1",
+ createdAt: new Date().toISOString(),
+ generationIssues: [],
+ phases: [
+ phaseFixture("phase-1", 0, "Overview"),
+ {
+ ...phase,
+ questions: [
+ {
+ id: "q-linked",
+ phaseId: phase.id,
+ position: 1,
+ type: "SHORT_TEXT",
+ question: "Who runs the retro?",
+ options: [],
+ status,
+ },
+ ],
+ },
+ ],
+ };
+ }
+
+ it("lands on a linked question and lights it up, without opening it", async () => {
+ const scrollIntoView = vi.spyOn(HTMLElement.prototype, "scrollIntoView");
+ server.use(
+ http.get("/api/v1/onboarding/me/path", () => HttpResponse.json(pathWithQuestion("OPEN"))),
+ );
+
+ const { container } = render(
+
+
+ ,
+ );
+
+ // Its phase, because a card the hire cannot see the context of is a link that only half arrived.
+ expect(await screen.findByRole("heading", { name: "Meetings", level: 2 })).toBeInTheDocument();
+ const card = container.querySelector("#onboarding-item-q-linked");
+ expect(card).toHaveClass("app-link-highlight");
+ await waitFor(() => expect(scrollIntoView).toHaveBeenCalled());
+ // Answering is the hire's click on the card, not something following a link does for them.
+ expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
+ });
+
+ it("lands on a linked step in a later phase and lights only that card", async () => {
+ server.use(
+ http.get("/api/v1/onboarding/me/path", () => HttpResponse.json(pathWithQuestion("OPEN"))),
+ );
+
+ const { container } = render(
+
+
+ ,
+ );
+
+ expect(await screen.findByRole("heading", { name: "Meetings", level: 2 })).toBeInTheDocument();
+ expect(container.querySelector("#onboarding-item-step-phase-2")).toHaveClass(
+ "app-link-highlight",
+ );
+ expect(container.querySelector("#onboarding-item-q-linked")).not.toHaveClass(
+ "app-link-highlight",
+ );
+ });
+
+ /**
+ * The line between a buddy link and `/onboarding/:stepId`: the address opens and starts a step,
+ * a link in the conversation only shows the hire where it is.
+ */
+ it("neither unfolds nor starts a step a link lands on", async () => {
+ const path = pathWithQuestion("OPEN");
+ path.phases[1].steps[0].status = "WAITING";
+ server.use(http.get("/api/v1/onboarding/me/path", () => HttpResponse.json(path)));
+ const startStep = vi.spyOn(onboardingService, "startStep");
+
+ const { container } = render(
+
+
+ ,
+ );
+
+ await waitFor(() =>
+ expect(container.querySelector("#onboarding-item-step-phase-2")).toHaveClass(
+ "app-link-highlight",
+ ),
+ );
+ expect(startStep).not.toHaveBeenCalled();
+ expect(
+ within(container.querySelector("#onboarding-item-step-phase-2")!).queryByRole("button", {
+ expanded: true,
+ }),
+ ).not.toBeInTheDocument();
+ });
+
+ /** Steps first, then questions -- the numbering `BuddyPathTools` gives the mentor. */
+ it("numbers the rows the way the buddy numbers them", async () => {
+ server.use(
+ http.get("/api/v1/onboarding/me/path", () => HttpResponse.json(pathWithQuestion("OPEN"))),
+ );
+
+ const { container } = render(
+
+
+ ,
+ );
+
+ await screen.findByRole("heading", { name: "Meetings", level: 2 });
+ expect(container.querySelector("#onboarding-item-step-phase-2")).toHaveTextContent("#1");
+ expect(container.querySelector("#onboarding-item-q-linked")).toHaveTextContent("#2");
+ });
+
+ it("names a question, not a step, when a question link points at nothing", async () => {
+ server.use(
+ http.get("/api/v1/onboarding/me/path", () => HttpResponse.json(pathWithQuestion("OPEN"))),
+ );
+
+ render(
+
+
+ ,
+ );
+
+ await waitFor(() =>
+ expect(toastMocks.error).toHaveBeenCalledWith(
+ "That question is not on your path",
+ expect.anything(),
+ ),
+ );
+ });
+
+ it("lands on the phase a link names", async () => {
+ server.use(
+ http.get("/api/v1/onboarding/me/path", () => HttpResponse.json(pathWithQuestion("OPEN"))),
+ );
+
+ render(
+
+
+ ,
+ );
+
+ expect(await screen.findByRole("heading", { name: "Meetings", level: 2 })).toBeInTheDocument();
+ });
+});
diff --git a/tests/unit/services/buddyService.test.ts b/tests/unit/services/buddyService.test.ts
index fb80a0d18..05abfabf0 100644
--- a/tests/unit/services/buddyService.test.ts
+++ b/tests/unit/services/buddyService.test.ts
@@ -254,7 +254,7 @@ describe("buddyService", () => {
start(controller) {
controller.enqueue(
encoder.encode(
- 'data: {"type":"action_proposal","action":"claim_task_zero","label":"Start Task 0"}\n\n',
+ 'data: {"type":"action_proposal","action":"claim_goal","label":"Work toward this task"}\n\n',
),
);
controller.enqueue(
@@ -284,8 +284,8 @@ describe("buddyService", () => {
});
expect(onActionProposal).toHaveBeenCalledWith({
- action: "claim_task_zero",
- label: "Start Task 0",
+ action: "claim_goal",
+ label: "Work toward this task",
question: undefined,
taskId: undefined,
});
@@ -515,14 +515,14 @@ describe("buddyService", () => {
server.use(
http.post("/api/v1/onboarding/me/buddy/actions", async ({ request }) => {
capturedBody = await request.json();
- return HttpResponse.json({ ok: true, message: "Task 0 is yours." });
+ return HttpResponse.json({ ok: true, message: "You are now working toward it." });
}),
);
- const result = await performAction("claim_task_zero");
+ const result = await performAction("claim_goal");
- expect(result).toEqual({ ok: true, message: "Task 0 is yours." });
- expect(capturedBody).toMatchObject({ action: "claim_task_zero" });
+ expect(result).toEqual({ ok: true, message: "You are now working toward it." });
+ expect(capturedBody).toMatchObject({ action: "claim_goal" });
});
it("sends the composed question for a flag-to-PM confirmation", async () => {
@@ -575,6 +575,58 @@ describe("buddyService", () => {
await performAction("reorder_cards", { cardIds: ["c-2", "c-1"] });
expect(capturedBody).toMatchObject({ action: "reorder_cards", cardIds: ["c-2", "c-1"] });
+
+ // The options a multiple-choice answer stands for: without them the backend cannot check the
+ // button still means what it said, and sends nothing.
+ await performAction("answer_question", {
+ questionId: "q-1",
+ answer: "Git, Docker",
+ optionIds: ["o-1", "o-2"],
+ });
+ expect(capturedBody).toMatchObject({
+ action: "answer_question",
+ questionId: "q-1",
+ answer: "Git, Docker",
+ optionIds: ["o-1", "o-2"],
+ });
+ });
+
+ it("carries the resolved options of an answer proposal off the stream", async () => {
+ const encoder = new TextEncoder();
+ const stream = new ReadableStream({
+ start(controller) {
+ controller.enqueue(
+ encoder.encode(
+ 'data: {"type":"action_proposal","action":"answer_question","label":"Send this answer: “Git, Docker”","question_id":"q-1","answer":"Git, Docker","option_ids":["o-1","o-2"]}\n\n',
+ ),
+ );
+ controller.enqueue(encoder.encode('data: {"type":"done"}\n\n'));
+ controller.close();
+ },
+ });
+ server.use(
+ http.post(
+ "/api/v1/onboarding/me/buddy/messages",
+ () => new HttpResponse(stream, { headers: { "Content-Type": "text/event-stream" } }),
+ ),
+ );
+
+ const onActionProposal = vi.fn();
+ await streamMessage("send both", {
+ onToken: vi.fn(),
+ onCitation: vi.fn(),
+ onDone: vi.fn(),
+ onActionProposal,
+ });
+
+ expect(onActionProposal).toHaveBeenCalledWith(
+ expect.objectContaining({
+ action: "answer_question",
+ questionId: "q-1",
+ answer: "Git, Docker",
+ optionIds: ["o-1", "o-2"],
+ }),
+ );
});
});