From 465bfc5202edc23497148c7945dd1d3809d1f378 Mon Sep 17 00:00:00 2001
From: DavidLeuter
Date: Sun, 13 Sep 2026 16:41:21 +0200
Subject: [PATCH 01/20] Put the tutor next to the path it is tutoring
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The buddy could not be reached from the one page it has most to say about. A hire stuck
on a step, or on a question they had now got wrong twice, was looking at the only page
in the product with nobody to ask — while a mentor sat in a dock that knew nothing
about any of it.
- `AskTheBuddy` on the phase, on every open step and on every unpassed question, plus
the step detail page. The same mechanism the board cards use: the surface seeds a
question, the mentor answers it with its own tools, so no surface needs action
machinery of its own.
- The question modal hands off too, and closes on the way out — the dock renders under
the modal, so opening it behind would have looked like nothing happening. Loudest
under a wrong answer, which is where a second guess used to be the only thing on
offer.
- The "no phases were generated" screen offers the conversation next to the retry.
Generating again is the wrong hope when the corpus was what was thin; talking it
through can actually produce something, and the mentor can offer to put the result on
the path.
- Openings live in `buddyDrafts.ts`, in the hire's voice, pre-filled rather than sent,
quoting a long title rather than pasting it.
- The path-changing actions announce themselves (`announceBuddyPathChanged`), so a step
completed in the conversation is not still open on the page behind the dock.
Co-Authored-By: Claude Opus 5
---
src/features/buddy/aiBuddyBus.ts | 25 ++++
.../buddy/hooks/useBuddyConversation.ts | 17 +++
src/features/buddy/types.ts | 32 +++++
src/features/onboarding/buddyDrafts.ts | 84 ++++++++++++++
.../components/OnBoardingItemPage.tsx | 11 ++
.../onboarding/components/QuestionModal.tsx | 43 ++++++-
src/pages/OnBoardingPage.tsx | 67 +++++++++++
src/services/buddyService.ts | 26 ++++-
.../features/onboarding/buddyDrafts.test.ts | 109 ++++++++++++++++++
9 files changed, 412 insertions(+), 2 deletions(-)
create mode 100644 src/features/onboarding/buddyDrafts.ts
create mode 100644 tests/unit/features/onboarding/buddyDrafts.test.ts
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/hooks/useBuddyConversation.ts b/src/features/buddy/hooks/useBuddyConversation.ts
index 699207efc..b0f801a92 100644
--- a/src/features/buddy/hooks/useBuddyConversation.ts
+++ b/src/features/buddy/hooks/useBuddyConversation.ts
@@ -6,6 +6,8 @@ import {
streamMessage,
type BuddyOpeningAction,
} from "../../../services/buddyService";
+import { announceBuddyPathChanged } from "../aiBuddyBus";
+import { BUDDY_PATH_ACTIONS } from "../types";
import type { BuddyMessageView, ProposedAction } from "../types";
/**
@@ -354,6 +356,11 @@ export function useBuddyConversation() {
githubLogin: proposal.githubLogin,
competencyKey: proposal.competencyKey,
level: proposal.level,
+ stepId: proposal.stepId,
+ questionId: proposal.questionId,
+ phaseId: proposal.phaseId,
+ answer: proposal.answer,
+ description: proposal.description,
status: "idle",
});
},
@@ -427,12 +434,22 @@ export function useBuddyConversation() {
githubLogin: action.githubLogin,
competencyKey: action.competencyKey,
level: action.level,
+ stepId: action.stepId,
+ questionId: action.questionId,
+ phaseId: action.phaseId,
+ answer: action.answer,
+ description: action.description,
});
patchAction(messageId, action.id, {
status: "resolved",
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 && BUDDY_PATH_ACTIONS.includes(action.action)) {
+ announceBuddyPathChanged();
+ }
} catch (e) {
console.error(e);
patchAction(messageId, action.id, { status: "error" });
diff --git a/src/features/buddy/types.ts b/src/features/buddy/types.ts
index 5605c3b9f..1b150c83e 100644
--- a/src/features/buddy/types.ts
+++ b/src/features/buddy/types.ts
@@ -17,6 +17,20 @@ export type ProposedActionStatus = "idle" | "confirming" | "resolved" | "error"
*/
export const BUDDY_ACTION_OPEN_ORIENTATION = "open_orientation";
+/**
+ * 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",
+ "answer_question",
+ "add_path_step",
+];
+
export type ProposedAction = {
/** Local id for keying and targeting the confirm — the backend doesn't assign one. */
id: string;
@@ -58,6 +72,19 @@ export type ProposedAction = {
*/
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;
+ answer?: string;
+ description?: string;
status: ProposedActionStatus;
/** Whether a resolved action actually changed something (false = a handled "couldn't"). */
ok?: boolean;
@@ -140,5 +167,10 @@ export type BuddyStreamHandlers = {
githubLogin?: string;
competencyKey?: string;
level?: string;
+ stepId?: string;
+ questionId?: string;
+ phaseId?: string;
+ answer?: string;
+ description?: string;
}) => void;
};
diff --git a/src/features/onboarding/buddyDrafts.ts b/src/features/onboarding/buddyDrafts.ts
new file mode 100644
index 000000000..0945a2ce6
--- /dev/null
+++ b/src/features/onboarding/buddyDrafts.ts
@@ -0,0 +1,84 @@
+// ============================================================
+// 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?`;
+}
+
+/** Opening a conversation about one step. */
+export function askAboutStep(step: OnboardingStepEndpoint): 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/OnBoardingItemPage.tsx b/src/features/onboarding/components/OnBoardingItemPage.tsx
index bcc3ebad5..3d61b9588 100644
--- a/src/features/onboarding/components/OnBoardingItemPage.tsx
+++ b/src/features/onboarding/components/OnBoardingItemPage.tsx
@@ -38,6 +38,8 @@ import {
ThumbsDown,
} from "lucide-react";
import { resolveNextAction } from "../nextAction";
+import { AskTheBuddy } from "../../buddy/components/AskTheBuddy";
+import { askAboutStep } from "../buddyDrafts";
type LoadingState = "idle" | "loading" | "success" | "error";
@@ -420,6 +422,15 @@ export function OnBoardingItemPage() {
{stepDetail.description}
+ {/* The step page is where a hire sits when they are stuck on one, and until now the
+ only things here were the task list and a Finish button. Offered while the step is
+ still open: there is nothing left to be stuck on once it is finished or skipped. */}
+ {stepDetail.status !== "FINISHED" && stepDetail.status !== "SKIPPED" && (
+
+ )}
{stepDetail.estimatedMinutes > 0 && (
diff --git a/src/features/onboarding/components/QuestionModal.tsx b/src/features/onboarding/components/QuestionModal.tsx
index 37f894804..af67d70c8 100644
--- a/src/features/onboarding/components/QuestionModal.tsx
+++ b/src/features/onboarding/components/QuestionModal.tsx
@@ -15,7 +15,9 @@ import type { OnboardingQuestionEndpoint, QuestionAttemptResult } from "../types
import { CheckQuestionCard } from "./CheckQuestionCard";
import { emptyDraft, isAnswered, toSubmission, type DraftAnswer } from "../checkAnswers";
import { ConfettiBurst } from "./ConfettiBurst";
-import { Loader2, RotateCcw, Trophy, XCircle } from "lucide-react";
+import { Loader2, MessageCircle, RotateCcw, Trophy, XCircle } from "lucide-react";
+import { openAiBuddy } from "../../buddy/aiBuddyBus";
+import { askAboutQuestion, askAboutWrongAnswer } from "../buddyDrafts";
interface QuestionModalProps {
question: OnboardingQuestionEndpoint;
@@ -84,6 +86,20 @@ export function QuestionModal({ question, phaseTitle, onClose }: QuestionModalPr
onboardingCompleted: result?.onboardingCompleted ?? false,
});
+ /**
+ * Hands the question over to the buddy.
+ *
+ * Closes the modal on the way out rather than opening the dock behind it: the dock renders under
+ * the modal, so a hire who asked for help would have watched nothing happen. Reported as an
+ * ordinary close — the mentor may go on to send an answer from the conversation, and the page
+ * re-reads the path when it does (see `announceBuddyPathChanged`), so this does not have to guess
+ * at what happens next.
+ */
+ const handOffToBuddy = (opening: string) => {
+ openAiBuddy({ draft: opening });
+ close();
+ };
+
const footer = result ? (
<>
{!result.correct && (
@@ -146,6 +162,17 @@ export function QuestionModal({ question, phaseTitle, onClose }: QuestionModalPr
Review the answer below and try again.
+ {/* Where a second guess used to be the only thing on offer. The buddy cannot tell them
+ the answer -- it is not given one -- so this is help with the material, which is what
+ a wrong answer actually calls for. */}
+
)}
@@ -158,6 +185,20 @@ export function QuestionModal({ question, phaseTitle, onClose }: QuestionModalPr
onToggleOption={toggleOption}
onTextChange={setTextAnswer}
/>
+
+ {/* Before an attempt, and deliberately quiet: guessing is allowed and costs nothing here, so
+ this is an offer rather than a nudge. Gone once the answer has been graded, where the
+ banner above carries the same offer with the reason for it. */}
+ {!result && (
+
+ )}
);
}
diff --git a/src/pages/OnBoardingPage.tsx b/src/pages/OnBoardingPage.tsx
index 0c53cea51..5f60cd8f7 100644
--- a/src/pages/OnBoardingPage.tsx
+++ b/src/pages/OnBoardingPage.tsx
@@ -10,6 +10,14 @@ import type {
OnboardingStepEndpoint,
} from "../features/onboarding/types";
import { findActivePhaseIndex } from "../features/onboarding/activePhase";
+import { AskTheBuddy } from "../features/buddy/components/AskTheBuddy";
+import { onBuddyPathChanged } from "../features/buddy/aiBuddyBus";
+import {
+ askAboutEmptyPhase,
+ askAboutPhase,
+ askAboutQuestion,
+ askAboutStep,
+} from "../features/onboarding/buddyDrafts";
import { useNavigate, useLocation } from "react-router-dom";
import { Badge } from "../components/ui/Badge";
import { Button } from "../components/ui/Button";
@@ -299,6 +307,19 @@ export function OnBoardingPage() {
return () => window.removeEventListener("keydown", onKeyDown);
}, [loadingState, gameActive, isUnlocked]);
+ /**
+ * Re-reads the path after the buddy changed it.
+ *
+ * The buddy lives in a dock over this page, which is the page a hire is most likely to be on while
+ * talking about their path. Without this, confirming "mark this step as done" in the conversation
+ * left the list behind it still showing the step open — their own click looking like it had done
+ * nothing. Told rather than polled; see `announceBuddyPathChanged`.
+ *
+ * Subscribed once: `refreshPath` only closes over the service and a setter, both stable for the
+ * life of the page.
+ */
+ useEffect(() => onBuddyPathChanged(() => void refreshPath()), []);
+
// ── DATA FETCHING using useEffect ─────────────────────────────
// Guards the initial GET against StrictMode's development-only effect replay.
@@ -582,6 +603,17 @@ export function OnBoardingPage() {
>
Try generation again
+ {/* Generating again is the wrong hope when the corpus is what was thin -- it will come
+ back empty a second time. The conversation is the one thing here that can actually
+ produce something, so it is offered next to the retry rather than instead of it. */}
+ {generationIssues.length > 0 && (
+
+ {/* Not on a finished or skipped step: there is nothing left to be
+ stuck on, and an invitation there is noise on a list of them. */}
+ {mode !== "completed" && (
+
+ )}
{/* Action depends on the step's mode:
@@ -948,6 +1002,19 @@ export function OnBoardingPage() {
? "Multiple choice"
: "Short text answer"}
+ {/* The tutoring moment. Offered on a question still open --
+ loudest on one already answered wrong, which is where a hire
+ previously had nowhere to go but another guess. */}
+ {mode !== "completed" && (
+
+ )}
diff --git a/src/services/buddyService.ts b/src/services/buddyService.ts
index 0caf521b5..704590b3d 100644
--- a/src/services/buddyService.ts
+++ b/src/services/buddyService.ts
@@ -120,6 +120,13 @@ interface BuddyStreamChunk {
github_login?: string;
competency_key?: string;
level?: string;
+ // Path-action confirm payloads: which node of the hire's own onboarding path the action names,
+ // the answer `answer_question` would send in their own words, and a new step's description.
+ step_id?: string;
+ question_id?: string;
+ phase_id?: string;
+ answer?: string;
+ description?: string;
}
/** The outcome of confirming a buddy-proposed action — a single line to relay in the thread. */
@@ -133,7 +140,9 @@ export interface BuddyActionResult {
* changed nothing. The project is re-resolved server-side from the caller, so only the action name
* and the proposal's own confirm payloads are sent: `question` for flag-to-PM, `taskId` for a
* goal claim, `title` + `attesterId` for an attestation request, `githubLogin` for saving a
- * username, `competencyKey` + `level` for recording where a conversation placed the hire.
+ * username, `competencyKey` + `level` for recording where a conversation placed the hire, and the
+ * path-node ids (`stepId`, `questionId`, `phaseId`, plus `answer` and `description`) for the three
+ * actions that move the hire along their onboarding path.
*/
export async function performAction(
action: string,
@@ -145,6 +154,11 @@ export async function performAction(
githubLogin?: string;
competencyKey?: string;
level?: string;
+ stepId?: string;
+ questionId?: string;
+ phaseId?: string;
+ answer?: string;
+ description?: string;
} = {},
): Promise {
return await apiClient.fetch(`/api/v1/onboarding/me/buddy/actions`, {
@@ -158,6 +172,11 @@ export async function performAction(
githubLogin: extras.githubLogin,
competencyKey: extras.competencyKey,
level: extras.level,
+ stepId: extras.stepId,
+ questionId: extras.questionId,
+ phaseId: extras.phaseId,
+ answer: extras.answer,
+ description: extras.description,
}),
});
}
@@ -327,6 +346,11 @@ export async function streamMessage(content: string, handlers: BuddyStreamHandle
githubLogin: event.github_login,
competencyKey: event.competency_key,
level: event.level,
+ stepId: event.step_id,
+ questionId: event.question_id,
+ phaseId: event.phase_id,
+ answer: event.answer,
+ description: event.description,
});
}
break;
diff --git a/tests/unit/features/onboarding/buddyDrafts.test.ts b/tests/unit/features/onboarding/buddyDrafts.test.ts
new file mode 100644
index 000000000..cfa1b91d8
--- /dev/null
+++ b/tests/unit/features/onboarding/buddyDrafts.test.ts
@@ -0,0 +1,109 @@
+import { describe, expect, it } from "vitest";
+import {
+ askAboutEmptyPhase,
+ 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,
+ }) as OnboardingPhaseEndpoint;
+
+ const step = (over: Partial = {}) =>
+ ({
+ id: "s1",
+ phaseId: "p1",
+ position: 0,
+ title: "Clone the repository",
+ description: "",
+ status: "WAITING",
+ ...over,
+ }) as OnboardingStepEndpoint;
+
+ const question = (over: Partial = {}) =>
+ ({
+ id: "q1",
+ phaseId: "p1",
+ position: 0,
+ type: "MULTIPLE_CHOICE",
+ question: "Which meeting sets the sprint scope?",
+ status: "OPEN",
+ ...over,
+ }) as 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("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"',
+ );
+ });
+});
From e56cae08a374739ac3baedc3bc36e7368112909f Mon Sep 17 00:00:00 2001
From: DavidLeuter
Date: Sun, 13 Sep 2026 18:59:04 +0200
Subject: [PATCH 02/20] Let a hire and their buddy point at the same thing
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Four of the six things testing turned up were on this side.
- **Links are clickable and stay in the app.** The mentor is handed each item's path, so
"want to take #3?" arrives as a link; `BuddyMarkdown` renders an app path through the
router instead of opening a new tab, which would reload the SPA and lose the
conversation. A question has no route of its own, so `?question=` opens its modal
and `?phase=` selects a phase — derived from the URL rather than copied into state,
so a link works whether it arrives on a fresh mount or on a click from the dock, and it
stops winning as soon as the hire closes the modal or picks another phase.
- **Items carry the number the buddy uses**, from one rule (`itemNumbers`) that matches
the Kotlin one: steps in position order, then questions. Not `position` itself — the two
kinds carry their own, so a hire counting down one visible list would have been right
while the data disagreed.
- **The step page refreshes itself** when the buddy changes something on it. Ticking a
line off in the dock left the checklist behind it unchanged — the hire's own click
looking like it had done nothing. Silent: the page is already on screen, and a spinner
over it is a worse answer than a stale tick box.
- `complete_task` wired through the proposal payloads.
Co-Authored-By: Claude Opus 5
---
.../buddy/components/BuddyMarkdown.tsx | 31 +++++-
.../buddy/hooks/useBuddyConversation.ts | 2 +
src/features/buddy/types.ts | 3 +
.../components/OnBoardingItemPage.tsx | 33 ++++++
src/features/onboarding/itemNumbers.ts | 34 ++++++
src/pages/OnBoardingPage.tsx | 104 ++++++++++++++++--
src/services/buddyService.ts | 4 +
.../features/onboarding/buddyDrafts.test.ts | 15 ++-
.../features/onboarding/itemNumbers.test.ts | 65 +++++++++++
tests/unit/pages/OnBoardingPage.test.tsx | 91 ++++++++++++++-
10 files changed, 365 insertions(+), 17 deletions(-)
create mode 100644 src/features/onboarding/itemNumbers.ts
create mode 100644 tests/unit/features/onboarding/itemNumbers.test.ts
diff --git a/src/features/buddy/components/BuddyMarkdown.tsx b/src/features/buddy/components/BuddyMarkdown.tsx
index 7f567da83..96d5ceb97 100644
--- a/src/features/buddy/components/BuddyMarkdown.tsx
+++ b/src/features/buddy/components/BuddyMarkdown.tsx
@@ -1,6 +1,21 @@
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.
+ */
+function isInAppPath(href: string | undefined): href is string {
+ return href !== undefined && href.startsWith("/") && !href.startsWith("//");
+}
+
/**
* 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
@@ -27,11 +42,17 @@ export function BuddyMarkdown({ 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 b0f801a92..2fcb19bf1 100644
--- a/src/features/buddy/hooks/useBuddyConversation.ts
+++ b/src/features/buddy/hooks/useBuddyConversation.ts
@@ -359,6 +359,7 @@ export function useBuddyConversation() {
stepId: proposal.stepId,
questionId: proposal.questionId,
phaseId: proposal.phaseId,
+ onboardingTaskId: proposal.onboardingTaskId,
answer: proposal.answer,
description: proposal.description,
status: "idle",
@@ -437,6 +438,7 @@ export function useBuddyConversation() {
stepId: action.stepId,
questionId: action.questionId,
phaseId: action.phaseId,
+ onboardingTaskId: action.onboardingTaskId,
answer: action.answer,
description: action.description,
});
diff --git a/src/features/buddy/types.ts b/src/features/buddy/types.ts
index 1b150c83e..804897a2d 100644
--- a/src/features/buddy/types.ts
+++ b/src/features/buddy/types.ts
@@ -27,6 +27,7 @@ export const BUDDY_ACTION_OPEN_ORIENTATION = "open_orientation";
*/
export const BUDDY_PATH_ACTIONS: readonly string[] = [
"complete_step",
+ "complete_task",
"answer_question",
"add_path_step",
];
@@ -83,6 +84,7 @@ export type ProposedAction = {
stepId?: string;
questionId?: string;
phaseId?: string;
+ onboardingTaskId?: string;
answer?: string;
description?: string;
status: ProposedActionStatus;
@@ -170,6 +172,7 @@ export type BuddyStreamHandlers = {
stepId?: string;
questionId?: string;
phaseId?: string;
+ onboardingTaskId?: string;
answer?: string;
description?: string;
}) => void;
diff --git a/src/features/onboarding/components/OnBoardingItemPage.tsx b/src/features/onboarding/components/OnBoardingItemPage.tsx
index 3d61b9588..a4bf6c542 100644
--- a/src/features/onboarding/components/OnBoardingItemPage.tsx
+++ b/src/features/onboarding/components/OnBoardingItemPage.tsx
@@ -39,6 +39,7 @@ import {
} from "lucide-react";
import { resolveNextAction } from "../nextAction";
import { AskTheBuddy } from "../../buddy/components/AskTheBuddy";
+import { onBuddyPathChanged } from "../../buddy/aiBuddyBus";
import { askAboutStep } from "../buddyDrafts";
type LoadingState = "idle" | "loading" | "success" | "error";
@@ -229,6 +230,38 @@ export function OnBoardingItemPage() {
}
};
+ /**
+ * Re-reads this step after the buddy changed something on it.
+ *
+ * The dock sits over this page, so ticking a line off in the conversation used to leave the
+ * checklist behind it unchanged — the hire's own click looking like it had done nothing. Silent on
+ * purpose: it re-reads the step and its tasks without going back through the loading state, because
+ * the page is already on screen and a spinner over it would be a worse answer than a stale tick
+ * box.
+ */
+ useEffect(
+ () =>
+ onBuddyPathChanged(() => {
+ if (!stepId) return;
+ void (async () => {
+ try {
+ const [step, refreshedTasks] = await Promise.all([
+ onboardingService.fetchStep(stepId),
+ onboardingService.fetchTasks(stepId),
+ ]);
+ setStepDetail(step);
+ setTasks(refreshedTasks);
+ setLocalFinished(
+ new Set(refreshedTasks.filter((task) => task.finished).map((task) => task.id)),
+ );
+ } catch (err) {
+ console.error("Failed to refresh the step after a buddy action:", err);
+ }
+ })();
+ }),
+ [stepId],
+ );
+
/**
* Data Fetching Effect: Loads the full hierarchy of a step (details, tasks, resources).
* It also initializes the local 'finished' state for tasks based on the fetched data.
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/pages/OnBoardingPage.tsx b/src/pages/OnBoardingPage.tsx
index 5f60cd8f7..321ccdcc7 100644
--- a/src/pages/OnBoardingPage.tsx
+++ b/src/pages/OnBoardingPage.tsx
@@ -2,7 +2,7 @@
// OnBoardingPage.tsx
// ============================================================
-import { useState, useEffect, useRef } from "react";
+import { useState, useEffect, useMemo, useRef } from "react";
import type {
OnboardingPathEndpoint,
OnboardingPhaseEndpoint,
@@ -10,6 +10,7 @@ import type {
OnboardingStepEndpoint,
} from "../features/onboarding/types";
import { findActivePhaseIndex } from "../features/onboarding/activePhase";
+import { itemNumbers } from "../features/onboarding/itemNumbers";
import { AskTheBuddy } from "../features/buddy/components/AskTheBuddy";
import { onBuddyPathChanged } from "../features/buddy/aiBuddyBus";
import {
@@ -18,7 +19,7 @@ import {
askAboutQuestion,
askAboutStep,
} from "../features/onboarding/buddyDrafts";
-import { useNavigate, useLocation } from "react-router-dom";
+import { useNavigate, useLocation, useSearchParams } from "react-router-dom";
import { Badge } from "../components/ui/Badge";
import { Button } from "../components/ui/Button";
import { onboardingService } from "../services/onboardingService";
@@ -137,6 +138,21 @@ export function OnBoardingPage() {
// user and the rest of their path, so this page can land on the question's phase.
const focusQuestionId = (location.state as { focusQuestionId?: string } | null)?.focusQuestionId;
+ /**
+ * Where a link from the buddy points.
+ *
+ * The mentor is given each item's path so it can say "want to take [#3](...)?" and have that be
+ * clickable. A question has no route of its own — it is a modal on this page — so it arrives as
+ * `?question=`, and a phase as `?phase=`.
+ *
+ * In the URL rather than in router state, unlike `focusQuestionId`: this link is written by the
+ * model into text the hire can copy, keep, or open in a second tab, and state does not survive any
+ * of that.
+ */
+ const [searchParams, setSearchParams] = useSearchParams();
+ const linkedQuestionId = searchParams.get("question");
+ const linkedPhaseId = searchParams.get("phase");
+
// The question list of the focused phase, so the page can scroll to it.
const questionListRef = useRef(null);
const hasFocusedQuestionRef = useRef(false);
@@ -195,6 +211,9 @@ export function OnBoardingPage() {
}) => {
const phases = OnBoardingPathEndpoint?.phases ?? [];
setQuestionToAnswer(null);
+ // Also the link, if that is what opened it — otherwise closing the modal would reopen it on the
+ // next render, since the URL would still be asking for it.
+ if (linkedQuestionId) clearLink();
// The backend decides completion; nothing here is derived from the phase alone.
if (onboardingCompleted) {
@@ -320,6 +339,37 @@ export function OnBoardingPage() {
*/
useEffect(() => onBuddyPathChanged(() => void refreshPath()), []);
+ /**
+ * The phase and the question a buddy link names, resolved from the URL rather than copied into
+ * state.
+ *
+ * Derived on purpose. A link can arrive two ways — a fresh mount, or a click while the hire is
+ * already standing on this page — and writing state from an effect would both trip the
+ * set-state-in-an-effect rule and only handle the first. Deriving handles both and needs no
+ * clean-up: the link stops winning the moment the hire picks a different phase or closes the
+ * modal, because those clear the parameter.
+ */
+ const linkedPhaseIndex = useMemo(() => {
+ const phases = OnBoardingPathEndpoint?.phases ?? [];
+ if (linkedQuestionId) {
+ return phases.findIndex((phase) =>
+ phase.questions.some((question) => question.id === linkedQuestionId),
+ );
+ }
+ return linkedPhaseId ? phases.findIndex((phase) => phase.id === linkedPhaseId) : -1;
+ }, [OnBoardingPathEndpoint, linkedQuestionId, linkedPhaseId]);
+
+ /** Forgets the link, so the hire's own next click decides what they are looking at. */
+ const clearLink = () =>
+ setSearchParams(
+ (params) => {
+ params.delete("question");
+ params.delete("phase");
+ return params;
+ },
+ { replace: true },
+ );
+
// ── DATA FETCHING using useEffect ─────────────────────────────
// Guards the initial GET against StrictMode's development-only effect replay.
@@ -363,7 +413,9 @@ export function OnBoardingPage() {
// makes a re-run a no-op anyway.
}, [focusQuestionId]);
- const currentPhase = OnBoardingPathEndpoint?.phases[selectedPhaseIndex] ?? null;
+ // A link from the buddy wins while it is in the URL; the hire's own tab click clears it.
+ const shownPhaseIndex = linkedPhaseIndex >= 0 ? linkedPhaseIndex : selectedPhaseIndex;
+ const currentPhase = OnBoardingPathEndpoint?.phases[shownPhaseIndex] ?? null;
const generationIssues = OnBoardingPathEndpoint?.generationIssues ?? [];
const generationIssueSummary = generationIssues
.map(
@@ -372,6 +424,30 @@ export function OnBoardingPage() {
)
.join(", ");
+ /**
+ * The question the modal is showing: the one a card opened, or the one a link names.
+ *
+ * A linked question only opens when the hire could actually answer it. A link to a locked or
+ * already-passed question is a dead end, and landing on its phase — which still happens — is the
+ * useful half of following it.
+ */
+ const linkedQuestion =
+ linkedQuestionId && currentPhase
+ ? currentPhase.questions.find(
+ (question) =>
+ question.id === linkedQuestionId &&
+ question.status !== "LOCKED" &&
+ question.status !== "PASSED",
+ )
+ : undefined;
+ const shownQuestion =
+ questionToAnswer ??
+ (linkedQuestion ? { question: linkedQuestion, phaseTitle: currentPhase?.title ?? "" } : null);
+
+ // The numbers this phase's items are shown with. The buddy's path tool derives the same ones, so
+ // "let's do 3" means one item on both sides — see `itemNumbers`.
+ const numbers = currentPhase ? itemNumbers(currentPhase) : new Map();
+
// Helper function for phase progress — steps and questions both count.
const getPhaseProgress = (phase: OnboardingPhaseEndpoint) => {
const questions = phase.questions ?? [];
@@ -679,7 +755,10 @@ export function OnBoardingPage() {
key={phase.id}
type="button"
aria-pressed={isSelected}
- onClick={() => setSelectedPhaseIndex(index)}
+ onClick={() => {
+ setSelectedPhaseIndex(index);
+ clearLink();
+ }}
className={`min-w-64 flex-1 rounded-2xl border p-4 text-left transition-all duration-200 motion-reduce:hover:scale-100 ${
isSelected
? "border-app-brand bg-app-brand-soft"
@@ -889,6 +968,12 @@ export function OnBoardingPage() {
: "text-app-text"
}`}
>
+ {/* The number the buddy uses for this item. Quiet, and not part of
+ the title: it is a handle for talking about the step, not
+ something the step is called. */}
+
+ #{numbers.get(step.id)}
+
{step.title}
@@ -1049,11 +1137,11 @@ export function OnBoardingPage() {
)}
- {/* Per-question answer modal */}
- {questionToAnswer && (
+ {/* Per-question answer modal. Opened by a card, or by a link the buddy wrote. */}
+ {shownQuestion && (
)}
diff --git a/src/services/buddyService.ts b/src/services/buddyService.ts
index 704590b3d..d9bcfa450 100644
--- a/src/services/buddyService.ts
+++ b/src/services/buddyService.ts
@@ -125,6 +125,7 @@ interface BuddyStreamChunk {
step_id?: string;
question_id?: string;
phase_id?: string;
+ onboarding_task_id?: string;
answer?: string;
description?: string;
}
@@ -157,6 +158,7 @@ export async function performAction(
stepId?: string;
questionId?: string;
phaseId?: string;
+ onboardingTaskId?: string;
answer?: string;
description?: string;
} = {},
@@ -175,6 +177,7 @@ export async function performAction(
stepId: extras.stepId,
questionId: extras.questionId,
phaseId: extras.phaseId,
+ onboardingTaskId: extras.onboardingTaskId,
answer: extras.answer,
description: extras.description,
}),
@@ -349,6 +352,7 @@ export async function streamMessage(content: string, handlers: BuddyStreamHandle
stepId: event.step_id,
questionId: event.question_id,
phaseId: event.phase_id,
+ onboardingTaskId: event.onboarding_task_id,
answer: event.answer,
description: event.description,
});
diff --git a/tests/unit/features/onboarding/buddyDrafts.test.ts b/tests/unit/features/onboarding/buddyDrafts.test.ts
index cfa1b91d8..5e3fd741f 100644
--- a/tests/unit/features/onboarding/buddyDrafts.test.ts
+++ b/tests/unit/features/onboarding/buddyDrafts.test.ts
@@ -32,7 +32,7 @@ describe("buddy drafts", () => {
steps: [],
questions: [],
...over,
- }) as OnboardingPhaseEndpoint;
+ }) satisfies OnboardingPhaseEndpoint;
const step = (over: Partial = {}) =>
({
@@ -41,9 +41,18 @@ describe("buddy drafts", () => {
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,
- }) as OnboardingStepEndpoint;
+ }) satisfies OnboardingStepEndpoint;
const question = (over: Partial = {}) =>
({
@@ -54,7 +63,7 @@ describe("buddy drafts", () => {
question: "Which meeting sets the sprint scope?",
status: "OPEN",
...over,
- }) as OnboardingQuestionEndpoint;
+ }) satisfies OnboardingQuestionEndpoint;
it("asks in the hire's own voice, not the page's", () => {
for (const draft of [
diff --git a/tests/unit/features/onboarding/itemNumbers.test.ts b/tests/unit/features/onboarding/itemNumbers.test.ts
new file mode 100644
index 000000000..c5e5e4e82
--- /dev/null
+++ b/tests/unit/features/onboarding/itemNumbers.test.ts
@@ -0,0 +1,65 @@
+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/pages/OnBoardingPage.test.tsx b/tests/unit/pages/OnBoardingPage.test.tsx
index b2f78ccf1..dfcd622f9 100644
--- a/tests/unit/pages/OnBoardingPage.test.tsx
+++ b/tests/unit/pages/OnBoardingPage.test.tsx
@@ -1,4 +1,4 @@
-import { render, screen, waitFor } from "@testing-library/react";
+import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { MemoryRouter } from "react-router-dom";
@@ -474,3 +474,92 @@ describe("OnBoardingPage", () => {
expect(screen.queryByRole("heading", { name: "Phase 1" })).not.toBeInTheDocument();
});
});
+
+/**
+ * Following a link the buddy wrote.
+ *
+ * The mentor is handed each item's path so that "want to take #3?" can be clickable. A question has
+ * no route of its own — it is a modal on this page — so it arrives as `?question=`, which also
+ * means the link survives being copied, kept or opened in a second tab.
+ */
+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("opens the question a link names, on its own phase", async () => {
+ server.use(http.get("/api/v1/onboarding/me/path", () => HttpResponse.json(pathWithQuestion("OPEN"))));
+
+ render(
+
+
+ ,
+ );
+
+ // The modal, and the phase behind it: a link lands on both, because a question the hire cannot
+ // see the context of is a link that only half arrived.
+ const dialog = await screen.findByRole("dialog");
+ expect(within(dialog).getByText("Who runs the retro?")).toBeInTheDocument();
+ expect(screen.getByRole("heading", { name: "Meetings", level: 2 })).toBeInTheDocument();
+ });
+
+ it("lands on the phase but opens nothing for a question that cannot be answered", async () => {
+ server.use(
+ http.get("/api/v1/onboarding/me/path", () => HttpResponse.json(pathWithQuestion("LOCKED"))),
+ );
+
+ render(
+
+
+ ,
+ );
+
+ // A modal over a locked question is a link that leads to a dead end; its phase is the useful half.
+ expect(
+ await screen.findByRole("heading", { name: "Meetings", level: 2 }),
+ ).toBeInTheDocument();
+ expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
+ });
+
+ 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();
+ });
+});
From ba829f664f8da2486231ee282da35f90346a9e9a Mon Sep 17 00:00:00 2001
From: DavidLeuter
Date: Sun, 13 Sep 2026 20:05:42 +0200
Subject: [PATCH 03/20] Keep the team page standing, and number what the buddy
numbers
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- **The member's path is normalised where it comes off the wire.** A phase without
`questions` took the whole team page down with a TypeError: three surfaces read it
because the type promised it, and the endpoint did not deliver. The backend now sends
it, and the default here means no caller downstream has to defend itself against the
same shape drift again.
- **The badge says who added a step**: the buddy, the hire, or a PM. It used to call all
three "Custom step by PM", which is the one of the three that matters — it is what the
team requires — so a step agreed to in a chat was arriving as an instruction from above.
- **Numbers in the graph view and on the step page**, from the same `itemNumbers` rule as
the list. A number the buddy uses and the hire cannot see on the page they are looking
at is worse than no number; the step page pays one path read for it, because a step
cannot know its own place among its siblings.
Co-Authored-By: Claude Opus 5
---
.../components/OnBoardingItemPage.tsx | 38 ++++++++++++-
.../components/OnboardingGraphViewer.tsx | 5 ++
.../onboarding/components/StepOriginBadge.tsx | 53 ++++++++++++++++---
src/features/onboarding/types.ts | 10 ++++
src/services/teamManagementService.ts | 30 +++++++----
.../components/StepOriginBadge.test.tsx | 49 +++++++++++++++++
6 files changed, 167 insertions(+), 18 deletions(-)
create mode 100644 tests/unit/features/onboarding/components/StepOriginBadge.test.tsx
diff --git a/src/features/onboarding/components/OnBoardingItemPage.tsx b/src/features/onboarding/components/OnBoardingItemPage.tsx
index a4bf6c542..45ab4acee 100644
--- a/src/features/onboarding/components/OnBoardingItemPage.tsx
+++ b/src/features/onboarding/components/OnBoardingItemPage.tsx
@@ -41,6 +41,7 @@ import { resolveNextAction } from "../nextAction";
import { AskTheBuddy } from "../../buddy/components/AskTheBuddy";
import { onBuddyPathChanged } from "../../buddy/aiBuddyBus";
import { askAboutStep } from "../buddyDrafts";
+import { itemNumbers } from "../itemNumbers";
type LoadingState = "idle" | "loading" | "success" | "error";
@@ -127,6 +128,34 @@ export function OnBoardingItemPage() {
const { flyby } = useMoments();
+ /**
+ * The number this step wears on the overview, so the two pages call it the same thing.
+ *
+ * Costs one read of the path, because a number is a fact about a step's *place among its
+ * siblings* and a single step cannot know it. Worth the request: the number is how a hire refers to
+ * this step when they ask their buddy about it, and a page that showed a different one — or none —
+ * would make that referring useless.
+ */
+ const [stepNumber, setStepNumber] = useState(null);
+
+ useEffect(() => {
+ if (!stepId) return;
+
+ void (async () => {
+ try {
+ const path = await onboardingService.fetchPath();
+ const phase = path.phases.find((candidate) =>
+ candidate.steps.some((step) => step.id === stepId),
+ );
+ setStepNumber(phase ? (itemNumbers(phase).get(stepId) ?? null) : null);
+ } catch {
+ // A number is decoration next to the title; a page that cannot fetch the path still shows
+ // the step. Silent on purpose -- the loader below reports anything that actually matters.
+ setStepNumber(null);
+ }
+ })();
+ }, [stepId]);
+
/**
* Works out what comes after this step, once the step is behind the user.
*
@@ -450,7 +479,14 @@ export function OnBoardingItemPage() {
: "Open"}
diff --git a/src/features/onboarding/components/OnboardingGraphViewer.tsx b/src/features/onboarding/components/OnboardingGraphViewer.tsx
index 991d8e75a..4582b7635 100644
--- a/src/features/onboarding/components/OnboardingGraphViewer.tsx
+++ b/src/features/onboarding/components/OnboardingGraphViewer.tsx
@@ -12,6 +12,7 @@ import type {
OnboardingStepEndpoint,
StepStatus,
} from "../types.ts";
+import { itemNumbers } from "../itemNumbers.ts";
type OnboardingGraphNode = BlueprintGraphCanvasNode & {
kind: "phase" | "step" | "question";
@@ -107,11 +108,15 @@ export function OnboardingGraphViewer({ path, selectedPhaseId, onSelectPhase }:
blockerIds: question.blockerIds,
}));
const nodes = [...steps, ...questions];
+ // The same numbers the list view prints and the buddy uses, so a node here can be talked about
+ // by the number the hire can see on it — which is the entire point of numbering them.
+ const numbers = itemNumbers(subGraphPhase);
return nodes.map((node, index) => {
const fallback = fallbackCoordinate(index, nodes.length);
return {
...node,
+ title: `#${numbers.get(node.id)} ${node.title}`,
graphX: node.graphX ?? fallback.x,
graphY: node.graphY ?? fallback.y,
blockerIds: node.blockerIds ?? (index > 0 ? [nodes[index - 1].id] : []),
diff --git a/src/features/onboarding/components/StepOriginBadge.tsx b/src/features/onboarding/components/StepOriginBadge.tsx
index 59baf6ed4..941acbb27 100644
--- a/src/features/onboarding/components/StepOriginBadge.tsx
+++ b/src/features/onboarding/components/StepOriginBadge.tsx
@@ -1,4 +1,4 @@
-import { UserRound } from "lucide-react";
+import { MessageCircle, Sparkles, UserRound } from "lucide-react";
import { Badge } from "../../../components/ui/Badge";
import type { OnboardingStepEndpoint } from "../types";
@@ -6,13 +6,50 @@ type StepOriginBadgeProps = {
step: OnboardingStepEndpoint;
};
+/**
+ * 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 rows written before `origin` existed: those default to
+ * `GENERATED` in the database while `isAiAssisted` still records that a person authored them. Their
+ * badge is the one they have always had.
+ */
export function StepOriginBadge({ step }: StepOriginBadgeProps) {
- if (step.isAiAssisted !== false) return null;
+ if (step.origin === "BUDDY") {
+ return (
+
+
+ Added with your buddy
+
+ );
+ }
- return (
-
-
- Custom step by PM
-
- );
+ if (step.origin === "HIRE") {
+ return (
+
+
+ You added this
+
+ );
+ }
+
+ // PM, or a pre-`origin` row that a person authored.
+ if (step.origin === "PM" || step.isAiAssisted === false) {
+ return (
+
+
+ Custom step by PM
+
+ );
+ }
+
+ return null;
}
diff --git a/src/features/onboarding/types.ts b/src/features/onboarding/types.ts
index fc30e3d96..bb19a1bd5 100644
--- a/src/features/onboarding/types.ts
+++ b/src/features/onboarding/types.ts
@@ -45,11 +45,21 @@ export interface OnboardingStepSkip {
reviewedAt: 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/services/teamManagementService.ts b/src/services/teamManagementService.ts
index 9033b5e4f..174731d59 100644
--- a/src/services/teamManagementService.ts
+++ b/src/services/teamManagementService.ts
@@ -337,6 +337,20 @@ export async function markOnboardingFeedbackRead(feedbackId: string): Promise {
@@ -354,22 +368,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/tests/unit/features/onboarding/components/StepOriginBadge.test.tsx b/tests/unit/features/onboarding/components/StepOriginBadge.test.tsx
new file mode 100644
index 000000000..0bb4fa66a
--- /dev/null
+++ b/tests/unit/features/onboarding/components/StepOriginBadge.test.tsx
@@ -0,0 +1,49 @@
+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("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();
+ });
+});
From 8bca818fc14218f6e89bc1f7eb4b3fdd4e25bf7a Mon Sep 17 00:00:00 2001
From: DavidLeuter
Date: Mon, 14 Sep 2026 13:55:58 +0200
Subject: [PATCH 04/20] Name the hire, not "you", when a PM reads the step
badge
The origin badge also sits on the team page, where "You added this" and
"Added with your buddy" read as being about the reviewer. The reviewer view
now says "Added by the hire" and "Added with the buddy".
Also formats the branch's onboarding files with Prettier.
Co-Authored-By: Claude Opus 5
---
src/features/onboarding/buddyDrafts.ts | 11 +++++-----
.../onboarding/components/StepOriginBadge.tsx | 20 ++++++++++++-------
.../detail/MemberOnboardingSection.tsx | 2 +-
.../components/detail/StepDetailsPanel.tsx | 2 +-
.../features/onboarding/buddyDrafts.test.ts | 4 +++-
.../components/StepOriginBadge.test.tsx | 13 ++++++++++++
.../features/onboarding/itemNumbers.test.ts | 4 +---
tests/unit/pages/OnBoardingPage.test.tsx | 16 +++++++--------
8 files changed, 46 insertions(+), 26 deletions(-)
diff --git a/src/features/onboarding/buddyDrafts.ts b/src/features/onboarding/buddyDrafts.ts
index 0945a2ce6..27d0458da 100644
--- a/src/features/onboarding/buddyDrafts.ts
+++ b/src/features/onboarding/buddyDrafts.ts
@@ -6,7 +6,11 @@
// for them.
// ============================================================
-import type { OnboardingPhaseEndpoint, OnboardingQuestionEndpoint, OnboardingStepEndpoint } from "./types";
+import type {
+ OnboardingPhaseEndpoint,
+ OnboardingQuestionEndpoint,
+ OnboardingStepEndpoint,
+} from "./types";
/**
* Taking what you are looking at on your path into the conversation.
@@ -68,10 +72,7 @@ export function askAboutStep(step: OnboardingStepEndpoint): string {
* 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 {
+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.`;
}
diff --git a/src/features/onboarding/components/StepOriginBadge.tsx b/src/features/onboarding/components/StepOriginBadge.tsx
index 941acbb27..864dab4a8 100644
--- a/src/features/onboarding/components/StepOriginBadge.tsx
+++ b/src/features/onboarding/components/StepOriginBadge.tsx
@@ -4,6 +4,11 @@ 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";
};
/**
@@ -18,16 +23,17 @@ type StepOriginBadgeProps = {
* 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 rows written before `origin` existed: those default to
- * `GENERATED` in the database while `isAiAssisted` still records that a person authored them. Their
- * badge is the one they have always had.
+ * **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 }: StepOriginBadgeProps) {
+export function StepOriginBadge({ step, viewer = "hire" }: StepOriginBadgeProps) {
if (step.origin === "BUDDY") {
return (
- Added with your buddy
+ {viewer === "hire" ? "Added with your buddy" : "Added with the buddy"}
);
}
@@ -36,12 +42,12 @@ export function StepOriginBadge({ step }: StepOriginBadgeProps) {
return (
- You added this
+ {viewer === "hire" ? "You added this" : "Added by the hire"}
);
}
- // PM, or a pre-`origin` row that a person authored.
+ // PM, or a hand-written blueprint copy or pre-`origin` row -- see above.
if (step.origin === "PM" || step.isAiAssisted === false) {
return (
diff --git a/src/features/team-management/components/detail/MemberOnboardingSection.tsx b/src/features/team-management/components/detail/MemberOnboardingSection.tsx
index 7179b65cb..4110fede1 100644
--- a/src/features/team-management/components/detail/MemberOnboardingSection.tsx
+++ b/src/features/team-management/components/detail/MemberOnboardingSection.tsx
@@ -569,7 +569,7 @@ function StepCard({
{step.title}
-
+
diff --git a/src/features/team-management/components/detail/StepDetailsPanel.tsx b/src/features/team-management/components/detail/StepDetailsPanel.tsx
index eef6dabd0..2398d5179 100644
--- a/src/features/team-management/components/detail/StepDetailsPanel.tsx
+++ b/src/features/team-management/components/detail/StepDetailsPanel.tsx
@@ -111,7 +111,7 @@ export function StepDetailsPanel({
>
{step.status.replace("_", " ")}
-
+
}
panelBackgroundClassName="bg-app-surface"
diff --git a/tests/unit/features/onboarding/buddyDrafts.test.ts b/tests/unit/features/onboarding/buddyDrafts.test.ts
index 5e3fd741f..3f55c4a54 100644
--- a/tests/unit/features/onboarding/buddyDrafts.test.ts
+++ b/tests/unit/features/onboarding/buddyDrafts.test.ts
@@ -81,7 +81,9 @@ describe("buddy drafts", () => {
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(
+ "Which meeting sets the sprint scope?",
+ );
expect(askAboutQuestion(question(), "Meetings")).toContain("Meetings");
});
diff --git a/tests/unit/features/onboarding/components/StepOriginBadge.test.tsx b/tests/unit/features/onboarding/components/StepOriginBadge.test.tsx
index 0bb4fa66a..46ec7e9be 100644
--- a/tests/unit/features/onboarding/components/StepOriginBadge.test.tsx
+++ b/tests/unit/features/onboarding/components/StepOriginBadge.test.tsx
@@ -27,6 +27,19 @@ describe("StepOriginBadge", () => {
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();
diff --git a/tests/unit/features/onboarding/itemNumbers.test.ts b/tests/unit/features/onboarding/itemNumbers.test.ts
index c5e5e4e82..53411a4e6 100644
--- a/tests/unit/features/onboarding/itemNumbers.test.ts
+++ b/tests/unit/features/onboarding/itemNumbers.test.ts
@@ -52,9 +52,7 @@ describe("itemNumbers", () => {
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)] }),
- );
+ const numbers = itemNumbers(phase({ steps: [step("s1", 0)], questions: [question("q1", 0)] }));
expect([...numbers.values()]).toEqual([1, 2]);
});
diff --git a/tests/unit/pages/OnBoardingPage.test.tsx b/tests/unit/pages/OnBoardingPage.test.tsx
index dfcd622f9..7eb1a2225 100644
--- a/tests/unit/pages/OnBoardingPage.test.tsx
+++ b/tests/unit/pages/OnBoardingPage.test.tsx
@@ -516,7 +516,9 @@ describe("OnBoardingPage: links from the buddy", () => {
}
it("opens the question a link names, on its own phase", async () => {
- server.use(http.get("/api/v1/onboarding/me/path", () => HttpResponse.json(pathWithQuestion("OPEN"))));
+ server.use(
+ http.get("/api/v1/onboarding/me/path", () => HttpResponse.json(pathWithQuestion("OPEN"))),
+ );
render(
@@ -543,14 +545,14 @@ describe("OnBoardingPage: links from the buddy", () => {
);
// A modal over a locked question is a link that leads to a dead end; its phase is the useful half.
- expect(
- await screen.findByRole("heading", { name: "Meetings", level: 2 }),
- ).toBeInTheDocument();
+ expect(await screen.findByRole("heading", { name: "Meetings", level: 2 })).toBeInTheDocument();
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
it("lands on the phase a link names", async () => {
- server.use(http.get("/api/v1/onboarding/me/path", () => HttpResponse.json(pathWithQuestion("OPEN"))));
+ server.use(
+ http.get("/api/v1/onboarding/me/path", () => HttpResponse.json(pathWithQuestion("OPEN"))),
+ );
render(
@@ -558,8 +560,6 @@ describe("OnBoardingPage: links from the buddy", () => {
,
);
- expect(
- await screen.findByRole("heading", { name: "Meetings", level: 2 }),
- ).toBeInTheDocument();
+ expect(await screen.findByRole("heading", { name: "Meetings", level: 2 })).toBeInTheDocument();
});
});
From 8e0ec3e3c04907a21aaa9e87fc7e9719e7c36b9e Mon Sep 17 00:00:00 2001
From: DavidLeuter
Date: Mon, 14 Sep 2026 15:28:44 +0200
Subject: [PATCH 05/20] Make picked suggestions sendable, and land buddy links
on the card
Picking a suggestion filled the composer but left focus on the chip, so Enter
sent nothing. Both composers now take the caret whenever text arrives from
outside rather than from typing -- chips, hand-offs from "Ask your buddy", any
of them.
A step or question link from the buddy now opens the item's phase, scrolls to
its card and lights it up briefly, instead of opening a question modal. Starting
the step or answering the question stays the hire's own click.
Co-Authored-By: Claude Opus 5
---
.../buddy/components/BuddyComposer.tsx | 27 ++++-
.../chatbot/components/ChatComposer.tsx | 24 +++-
src/pages/OnBoardingPage.tsx | 105 +++++++++++-------
src/styles/index.css | 34 ++++++
.../features/buddy/BuddyComposer.test.tsx | 46 ++++++++
tests/unit/pages/OnBoardingPage.test.tsx | 42 ++++---
6 files changed, 218 insertions(+), 60 deletions(-)
create mode 100644 tests/unit/features/buddy/BuddyComposer.test.tsx
diff --git a/src/features/buddy/components/BuddyComposer.tsx b/src/features/buddy/components/BuddyComposer.tsx
index 6875a604c..de00fb6f3 100644
--- a/src/features/buddy/components/BuddyComposer.tsx
+++ b/src/features/buddy/components/BuddyComposer.tsx
@@ -58,6 +58,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]);
+
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Enter" || event.shiftKey) return;
// Enter also *commits* an IME candidate — a compose-key 'ü', or any CJK input. Sending
@@ -85,7 +107,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/chatbot/components/ChatComposer.tsx b/src/features/chatbot/components/ChatComposer.tsx
index 8d903894e..40938c5d1 100644
--- a/src/features/chatbot/components/ChatComposer.tsx
+++ b/src/features/chatbot/components/ChatComposer.tsx
@@ -1,5 +1,5 @@
import { Check, Filter, Send, Square, X } from "lucide-react";
-import { useState } from "react";
+import { useEffect, useRef, useState } from "react";
import type { FormEvent, RefObject } from "react";
import { SOURCE_META } from "../../data-ingestion/data";
import type { SourceSystem } from "../types";
@@ -111,6 +111,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]);
+
return (
,
);
- await waitFor(() => expect(screen.getByLabelText("Your path here")).toBeInTheDocument());
+ await waitFor(() => expect(screen.getByText("Your open pull requests")).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 c277fd913..09bc6a28c 100644
--- a/tests/unit/features/board/BoardGrid.test.tsx
+++ b/tests/unit/features/board/BoardGrid.test.tsx
@@ -8,27 +8,9 @@ import type {
BoardCard,
CurrentTaskContent,
OpenPullRequestsContent,
- PathToFirstContributionContent,
SuggestedTasksContent,
} from "../../../../src/features/board/types";
-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 => ({
@@ -44,7 +26,6 @@ const currentTaskContent = (over: Partial = {}): CurrentTask
title: "Fix the flaky login test",
summary: "It fails about one run in five.",
url: null,
- chosen: true,
...over,
});
@@ -72,41 +53,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();
@@ -184,9 +130,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);
@@ -194,20 +140,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 846ba4d3a..f060c153e 100644
--- a/tests/unit/features/board/BoardSubmenus.test.tsx
+++ b/tests/unit/features/board/BoardSubmenus.test.tsx
@@ -53,26 +53,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("claiming a suggested task goes through the buddy, not around the confirm gate", () => {
render(
{
title: "Fix the flaky login test",
summary: null,
url: null,
- chosen: true,
},
])}
/>,
diff --git a/tests/unit/features/board/Marked.test.tsx b/tests/unit/features/board/Marked.test.tsx
index a2508a7c5..0ea738a61 100644
--- a/tests/unit/features/board/Marked.test.tsx
+++ b/tests/unit/features/board/Marked.test.tsx
@@ -81,7 +81,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,
}}
card={card}
/>,
diff --git a/tests/unit/features/board/cardAccents.test.ts b/tests/unit/features/board/cardAccents.test.ts
index 5e0312bd0..266a06406 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 63df67548..8050f2243 100644
--- a/tests/unit/features/board/useBoard.test.tsx
+++ b/tests/unit/features/board/useBoard.test.tsx
@@ -23,17 +23,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 },
})),
});
@@ -125,7 +119,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 51e87c002..f6ae48d12 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 = {}): ProposedAction {
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(
,
diff --git a/tests/unit/services/buddyService.test.ts b/tests/unit/services/buddyService.test.ts
index 62d4afd38..6f55f9bbc 100644
--- a/tests/unit/services/buddyService.test.ts
+++ b/tests/unit/services/buddyService.test.ts
@@ -247,7 +247,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(
@@ -277,8 +277,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,
});
@@ -453,14 +453,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 () => {
From f7f4e0667d11625608f9abb7d251b43c0863d0c7 Mon Sep 17 00:00:00 2001
From: DavidLeuter
Date: Tue, 15 Sep 2026 12:53:18 +0200
Subject: [PATCH 10/20] Call the PM metrics contribution metrics
They measure joining to a first accepted contribution and review
waits, not onboarding progress, which is the path. Only visible wording
changes; the route and file names still say onboarding.
Refs #311
Co-Authored-By: Claude Opus 5
---
.../components/HireTimelineCard.tsx | 2 +-
.../components/OnboardingMetricsPage.tsx | 24 +++++++++++--------
.../components/OnboardingMetricsWidget.tsx | 10 ++++----
3 files changed, 20 insertions(+), 16 deletions(-)
diff --git a/src/features/onboarding-metrics/components/HireTimelineCard.tsx b/src/features/onboarding-metrics/components/HireTimelineCard.tsx
index 688ecfb8b..48b631f62 100644
--- a/src/features/onboarding-metrics/components/HireTimelineCard.tsx
+++ b/src/features/onboarding-metrics/components/HireTimelineCard.tsx
@@ -33,7 +33,7 @@ function gapHours(from: string | null, to: string | null): number | null {
}
/**
- * 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 5eba22812..cb18df051 100644
--- a/src/features/onboarding-metrics/components/OnboardingMetricsPage.tsx
+++ b/src/features/onboarding-metrics/components/OnboardingMetricsPage.tsx
@@ -69,8 +69,12 @@ function hasActivity(hires: HireTimeline[]): boolean {
}
/**
- * 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
@@ -129,12 +133,12 @@ export function OnboardingMetricsPage() {
pendingRefreshRef.current = false;
setRefreshing(false);
if (error) {
- 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.",
});
@@ -152,8 +156,8 @@ export function OnboardingMetricsPage() {
}
if (!errorToastRef.current && !pendingRefreshRef.current) {
errorToastRef.current = true;
- toast.error("Couldn't load onboarding metrics", {
- description: "The onboarding metrics couldn't be loaded. Try again shortly.",
+ toast.error("Couldn't load contribution metrics", {
+ description: "The contribution metrics couldn't be loaded. Try again shortly.",
});
}
}, [loading, error, toast]);
@@ -244,8 +248,8 @@ export function OnboardingMetricsPage() {
{refreshButton}
@@ -266,11 +270,11 @@ export function OnboardingMetricsPage() {
icon={}
title="Couldn't load metrics"
>
- The onboarding metrics couldn't be loaded. Try again shortly.
+ The contribution metrics couldn't be loaded. Try again shortly.
) : !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-metrics/components/OnboardingMetricsWidget.tsx b/src/features/onboarding-metrics/components/OnboardingMetricsWidget.tsx
index 97cb75d7f..b842a2b84 100644
--- a/src/features/onboarding-metrics/components/OnboardingMetricsWidget.tsx
+++ b/src/features/onboarding-metrics/components/OnboardingMetricsWidget.tsx
@@ -80,10 +80,10 @@ function AttentionRow({ item }: { item: AttentionItem }) {
}
/**
- * Compact PM-dashboard summary of onboarding health, a peer card to the other
+ * Compact PM-dashboard summary of how hires get their work in, a peer card to the other
* Insights widgets (`FaqWidget`, `KnowledgeGapWidget`). It deliberately carries
* only the two questions a PM answers at a glance — how many hires are stuck, and
- * how fast onboarding reaches a first accepted piece of work — plus a short "who
+ * how fast a hire reaches a first accepted piece of work — plus a short "who
* needs a human" preview; everything else lives on the full readout it links to.
*
* The metrics are derived on request, so "refresh" is a client-side refetch
@@ -125,7 +125,7 @@ export function OnboardingMetricsWidget() {
try {
await reloadAttention();
} catch {
- toast.error("Couldn't refresh onboarding metrics", { description: "Try again shortly." });
+ toast.error("Couldn't refresh contribution metrics", { description: "Try again shortly." });
} finally {
setRefreshing(false);
}
@@ -144,7 +144,7 @@ export function OnboardingMetricsWidget() {
- Onboarding metrics couldn't be loaded for this project.
+ Contribution metrics couldn't be loaded for this project.
- ) : 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.
+
+ )}
) : 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/pages/OnBoardingPage.tsx b/src/pages/OnBoardingPage.tsx
index dedcfec10..a54291bfa 100644
--- a/src/pages/OnBoardingPage.tsx
+++ b/src/pages/OnBoardingPage.tsx
@@ -70,6 +70,8 @@ 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 { askAboutEmptyPhase, askAboutPhase } from "../features/onboarding/buddyDrafts";
import { ApiError } from "../services/apiClient";
import { onboardingGraphService } from "../services/onboardingGraphService";
import { onboardingService } from "../services/onboardingService";
@@ -700,6 +702,7 @@ export function OnBoardingPage() {
handleAnswered(item.question, result)}
continueLabel={next.label}
onContinue={next.run}
@@ -832,6 +835,15 @@ export function OnBoardingPage() {
>
Try generation again
+ {/* 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 rather than instead of it. */}
+ {generationIssues.length > 0 ? (
+
+ ) : null}
);
}
@@ -1097,6 +1109,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 (
@@ -1110,6 +1123,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. */}
+
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..11c1fdc64 100644
--- a/tests/unit/features/onboarding/components/journey/StepWorkspace.test.tsx
+++ b/tests/unit/features/onboarding/components/journey/StepWorkspace.test.tsx
@@ -22,6 +22,13 @@ vi.mock("../../../../../../src/services/onboardingService", () => ({
},
}));
+const mockOpenAiBuddy = vi.hoisted(() => vi.fn());
+
+vi.mock("../../../../../../src/features/buddy/aiBuddyBus", () => ({
+ openAiBuddy: mockOpenAiBuddy,
+ onBuddyPathChanged: () => () => undefined,
+}));
+
import { onboardingService } from "../../../../../../src/services/onboardingService";
const step = {
@@ -231,6 +238,27 @@ 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("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",
From c1ecb0e7677b0e33880c470684e893d8ddce77d1 Mon Sep 17 00:00:00 2001
From: DavidLeuter
Date: Thu, 24 Sep 2026 16:53:10 +0200
Subject: [PATCH 15/20] Refresh every path surface after the buddy changes the
path
The dock confirms path actions (tick a step or a line off, answer, add a
step, request a skip) over whatever page is open, and announces it with
`announceBuddyPathChanged`. After the journey rework nothing listened
any more, so the page behind the dock kept showing the old state.
- The onboarding page re-reads the path; an open step re-reads its
tasks and status.
- The board's "where you are" strip reads the path again.
- `useBuddyPathSync`, mounted once in the app, marks the board and the
onboarding status queries stale, which covers the PATH_STEP cards and
the dashboard's next-step card.
Refs #311
Co-Authored-By: Claude Opus 5.5
---
src/App.tsx | 2 +
.../board/components/BoardPathWindow.tsx | 24 ++++++++----
src/features/buddy/hooks/useBuddyPathSync.ts | 25 +++++++++++++
.../components/journey/StepWorkspace.tsx | 8 +++-
src/pages/OnBoardingPage.tsx | 11 ++++++
.../features/buddy/useBuddyPathSync.test.tsx | 28 ++++++++++++++
.../components/journey/StepWorkspace.test.tsx | 20 ++++++++--
tests/unit/pages/OnBoardingPage.test.tsx | 37 ++++++++++++++++++-
8 files changed, 142 insertions(+), 13 deletions(-)
create mode 100644 src/features/buddy/hooks/useBuddyPathSync.ts
create mode 100644 tests/unit/features/buddy/useBuddyPathSync.test.tsx
diff --git a/src/App.tsx b/src/App.tsx
index 68f5cf5c0..1d88dc394 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -21,12 +21,14 @@ import { AuroraBackground } from "./components/layout/AuroraBackground";
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";
function AppContent() {
const { status } = useAuth();
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/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/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/onboarding/components/journey/StepWorkspace.tsx b/src/features/onboarding/components/journey/StepWorkspace.tsx
index fe22b7c02..a0adf260f 100644
--- a/src/features/onboarding/components/journey/StepWorkspace.tsx
+++ b/src/features/onboarding/components/journey/StepWorkspace.tsx
@@ -28,6 +28,7 @@ import type {
OnboardingTaskEndpoint,
} from "../../types";
import { AskTheBuddy } from "../../../buddy/components/AskTheBuddy";
+import { onBuddyPathChanged } from "../../../buddy/aiBuddyBus";
import { askAboutStep } from "../../buddyDrafts";
import { StepOriginBadge } from "../StepOriginBadge";
import { TaskCheckItem } from "../TaskCheckItem";
@@ -96,6 +97,11 @@ export function StepWorkspace({
const [comment, setComment] = useState("");
const [feedbackSent, setFeedbackSent] = useState(false);
const [now, setNow] = useState(() => 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);
@@ -142,7 +148,7 @@ export function StepWorkspace({
return () => {
cancelled = true;
};
- }, [stepId, stepStatus]);
+ }, [stepId, stepStatus, buddyChanges]);
if (error) {
return (
diff --git a/src/pages/OnBoardingPage.tsx b/src/pages/OnBoardingPage.tsx
index a54291bfa..81f61376b 100644
--- a/src/pages/OnBoardingPage.tsx
+++ b/src/pages/OnBoardingPage.tsx
@@ -71,6 +71,7 @@ import type {
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, askAboutPhase } from "../features/onboarding/buddyDrafts";
import { ApiError } from "../services/apiClient";
import { onboardingGraphService } from "../services/onboardingGraphService";
@@ -299,6 +300,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.
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/components/journey/StepWorkspace.test.tsx b/tests/unit/features/onboarding/components/journey/StepWorkspace.test.tsx
index 11c1fdc64..2c2fb2383 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";
@@ -24,12 +24,14 @@ vi.mock("../../../../../../src/services/onboardingService", () => ({
const mockOpenAiBuddy = vi.hoisted(() => vi.fn());
-vi.mock("../../../../../../src/features/buddy/aiBuddyBus", () => ({
+// 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,
- onBuddyPathChanged: () => () => undefined,
}));
import { onboardingService } from "../../../../../../src/services/onboardingService";
+import { announceBuddyPathChanged } from "../../../../../../src/features/buddy/aiBuddyBus";
const step = {
id: "step1",
@@ -251,6 +253,18 @@ describe("StepWorkspace", () => {
});
});
+ 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("does not offer the buddy on a step that is behind the hire", async () => {
vi.mocked(onboardingService.fetchStep).mockResolvedValue({ ...step, status: "FINISHED" });
renderWorkspace();
diff --git a/tests/unit/pages/OnBoardingPage.test.tsx b/tests/unit/pages/OnBoardingPage.test.tsx
index c1b4c69f9..2e68053a4 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,
@@ -871,6 +872,40 @@ describe("OnBoardingPage", () => {
* 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();
From c0fe1999755cdf40ae82835564df7d5045e5c935 Mon Sep 17 00:00:00 2001
From: DavidLeuter
Date: Thu, 24 Sep 2026 16:54:15 +0200
Subject: [PATCH 16/20] Name the hire, not the reader, on the PM's journey view
The step origin badge says "You added this" to the hire. The journey
rework's member view reused it without `viewer="reviewer"`, so a PM read
a hire's own step as one they had added themselves.
Refs #311
Co-Authored-By: Claude Opus 5.5
---
.../detail/MemberJourneySection.tsx | 2 +-
.../detail/MemberJourneySection.test.tsx | 21 +++++++++++++++++++
2 files changed, 22 insertions(+), 1 deletion(-)
diff --git a/src/features/team-management/components/detail/MemberJourneySection.tsx b/src/features/team-management/components/detail/MemberJourneySection.tsx
index 7a4b5574c..6e2116775 100644
--- a/src/features/team-management/components/detail/MemberJourneySection.tsx
+++ b/src/features/team-management/components/detail/MemberJourneySection.tsx
@@ -660,7 +660,7 @@ function StepFacts({ item, taskCount }: { item: PhaseItem; taskCount?: StepTaskC
const delta = actual && item.step.estimatedMinutes ? actual - item.step.estimatedMinutes : null;
return (
-
+
{taskCount ? (
{taskCount.done}/{taskCount.total} tasks
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 d2d03e0b7..1e2ffcc6f 100644
--- a/tests/unit/features/team-management/components/detail/MemberJourneySection.test.tsx
+++ b/tests/unit/features/team-management/components/detail/MemberJourneySection.test.tsx
@@ -182,6 +182,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("says so when the member has no path yet", () => {
renderSection({ path: null });
From b134578530c2b603d7e0b468720709ab691f7626 Mon Sep 17 00:00:00 2001
From: DavidLeuter
Date: Thu, 24 Sep 2026 19:32:42 +0200
Subject: [PATCH 17/20] Don't start a step that is only opened to change its
skip request
The buddy links a step with a pending skip request to `/onboarding/`
so the hire can change or withdraw the reason, which lives in the
unfolded step. The journey page starts a waiting step when it unfolds
it, so following that link began the very step the hire asked to skip.
A step with a pending skip is now opened without being started; its own
"Start" button still starts it.
Refs #311
Co-Authored-By: Claude Opus 5.5
---
src/pages/OnBoardingPage.tsx | 4 ++
tests/unit/pages/OnBoardingPage.test.tsx | 47 ++++++++++++++++++++++++
2 files changed, 51 insertions(+)
diff --git a/src/pages/OnBoardingPage.tsx b/src/pages/OnBoardingPage.tsx
index 81f61376b..83e2fd66e 100644
--- a/src/pages/OnBoardingPage.tsx
+++ b/src/pages/OnBoardingPage.tsx
@@ -50,6 +50,7 @@ import { ProgressRing } from "../features/onboarding/graph/JourneyNodeCards";
import { usePathRevealMoment } from "../features/onboarding/hooks/usePathRevealMoment";
import {
blockingPhases,
+ isSkipPending,
itemState,
pathProgress,
phaseItems,
@@ -534,6 +535,9 @@ export function OnBoardingPage() {
/** A step the member opens for the first time is started; reopening one changes nothing. */
const beginStepIfWaiting = async (item: PhaseItem) => {
if (item.kind !== "step" || item.step.status !== "WAITING" || item.step.locked) return;
+ // Opened to change or withdraw a skip request -- which is what the buddy's link to the step is
+ // for -- is not beginning it. The step's own "Start" button still does that.
+ if (isSkipPending(item.step.skip)) return;
try {
await onboardingService.startStep(item.step.id);
// The rocket marks a step *beginning*.
diff --git a/tests/unit/pages/OnBoardingPage.test.tsx b/tests/unit/pages/OnBoardingPage.test.tsx
index 2e68053a4..01d5b0866 100644
--- a/tests/unit/pages/OnBoardingPage.test.tsx
+++ b/tests/unit/pages/OnBoardingPage.test.tsx
@@ -707,6 +707,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", () =>
From faac83d1406799a4d3af12af39166ab3ae265c00 Mon Sep 17 00:00:00 2001
From: DavidLeuter
Date: Thu, 24 Sep 2026 20:03:01 +0200
Subject: [PATCH 18/20] Tidy the buddy-link arrival and two misplaced comments
A dead question link now says it was a question, the reduced-motion link
highlight goes away like the animated one instead of staying for good,
and the linkedCardId and DependencySource doc comments sit where they
belong.
Co-Authored-By: Claude Opus 5.5
---
src/features/board/layout/boardStructure.ts | 2 +-
src/features/onboarding/journey.ts | 2 +-
src/pages/OnBoardingPage.tsx | 21 ++++++++------
src/styles/index.css | 15 ++++++++--
tests/unit/pages/OnBoardingPage.test.tsx | 31 +++++++++++++++++++++
5 files changed, 57 insertions(+), 14 deletions(-)
diff --git a/src/features/board/layout/boardStructure.ts b/src/features/board/layout/boardStructure.ts
index 7e2b3b4fa..40de638be 100644
--- a/src/features/board/layout/boardStructure.ts
+++ b/src/features/board/layout/boardStructure.ts
@@ -90,11 +90,11 @@ export function stageOrder(stage: BoardStage): number {
* - `TEAM` — from a card blueprint. The PM's, and the hire may not take it off.
* - `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.
- * - `HIRE` — theirs, and the only kind their own controls write.
*/
export type DependencySource = "TEAM" | "BUDDY" | "HIRE";
diff --git a/src/features/onboarding/journey.ts b/src/features/onboarding/journey.ts
index 371730f82..374201f48 100644
--- a/src/features/onboarding/journey.ts
+++ b/src/features/onboarding/journey.ts
@@ -91,12 +91,12 @@ export function phaseItems(phase: OnboardingPhaseEndpoint): PhaseItem[] {
return [...steps, ...questions];
}
-/** A phase's items in the order its graph reads, top to bottom -- the list view's order. */
/** 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/pages/OnBoardingPage.tsx b/src/pages/OnBoardingPage.tsx
index 83e2fd66e..ca9a85cc2 100644
--- a/src/pages/OnBoardingPage.tsx
+++ b/src/pages/OnBoardingPage.tsx
@@ -182,7 +182,8 @@ export function OnBoardingPage() {
* conversation is a way of finding something, and starting it is the hire's own click.
*/
const [searchParams, setSearchParams] = useSearchParams();
- const linkedItemId = searchParams.get("step") ?? searchParams.get("question");
+ const linkedStepId = searchParams.get("step");
+ const linkedQuestionId = searchParams.get("question");
const linkedPhaseId = searchParams.get("phase");
const [path, setPath] = useState(null);
@@ -365,12 +366,14 @@ export function OnBoardingPage() {
? { kind: "question" as const, id: focusQuestionId }
: wantsChooser
? { kind: "choose" as const, id: "" }
- : linkedItemId
- ? { kind: "link-item" as const, id: linkedItemId }
- : linkedPhaseId
- ? { kind: "link-phase" as const, id: linkedPhaseId }
- : null,
- [focusQuestionId, linkedItemId, linkedPhaseId, 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],
);
// 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.
@@ -425,7 +428,7 @@ export function OnBoardingPage() {
);
if (!owningPhase) {
toast.error(
- arrival.kind === "question"
+ 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." },
@@ -436,7 +439,7 @@ export function OnBoardingPage() {
return;
}
- if (arrival.kind === "link-item") {
+ if (arrival.kind === "link-step" || arrival.kind === "link-question") {
setSelectedPhaseId(owningPhase.id);
setExpandedItemId(null);
setLinkHighlight({ id: arrival.id, key: arrivalKey });
diff --git a/src/styles/index.css b/src/styles/index.css
index bc0208632..b9540faa8 100644
--- a/src/styles/index.css
+++ b/src/styles/index.css
@@ -968,11 +968,20 @@ body {
animation: app-link-highlight 1.6s ease-in-out 0.35s 1 both;
}
+/* 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) {
- /* No pulse, but still marked: finding the item is the point, not the motion. */
.app-link-highlight {
- animation: none;
- box-shadow: 0 0 0 2px var(--color-app-brand);
+ animation: app-link-highlight-still 2s step-end 1 both;
}
}
diff --git a/tests/unit/pages/OnBoardingPage.test.tsx b/tests/unit/pages/OnBoardingPage.test.tsx
index 01d5b0866..4057b8a1f 100644
--- a/tests/unit/pages/OnBoardingPage.test.tsx
+++ b/tests/unit/pages/OnBoardingPage.test.tsx
@@ -28,6 +28,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", () => ({
@@ -1073,6 +1085,25 @@ describe("OnBoardingPage: links from the buddy", () => {
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"))),
From cbbe63b7c4259448fcf6f08b4b3e95d842709297 Mon Sep 17 00:00:00 2001
From: DavidLeuter
Date: Sat, 26 Sep 2026 13:14:59 +0200
Subject: [PATCH 19/20] Address review on the buddy path links and step drafts
- Scroll to a linked card once the list has slid in, and only once per link
- Clear the link highlight when it has played or the phase changes; key rows by item
so a link no longer remounts an open step, and let the pulse end with `backwards`
- Keep a half-typed skip reason or feedback comment when the buddy changes the path,
and skip the second step read when the path catches up
- Keep the item open when a link points at it; let go of an open graph item on a link
- Hold back starting a skip-pending step only for `/onboarding/` arrivals
- Reject `/\host` links; offer the buddy for every empty phase, not just the first
- PenLine icon for hire-added steps, drop an unneeded `questions` fallback, fix a stale comment
Co-Authored-By: Claude Opus 5.5
---
src/features/board/layout/boardGroups.ts | 2 +-
.../buddy/components/BuddyMarkdown.tsx | 5 +-
src/features/onboarding/buddyDrafts.ts | 11 +++
.../onboarding/components/StepOriginBadge.tsx | 4 +-
.../components/journey/PhaseItemList.tsx | 25 +++++--
.../components/journey/StepWorkspace.tsx | 32 +++++++-
src/pages/OnBoardingPage.tsx | 75 +++++++++++++------
src/styles/index.css | 5 +-
.../features/buddy/BuddyMarkdown.test.tsx | 35 +++++++++
.../features/onboarding/buddyDrafts.test.ts | 8 ++
.../components/journey/StepWorkspace.test.tsx | 25 +++++++
11 files changed, 189 insertions(+), 38 deletions(-)
create mode 100644 tests/unit/features/buddy/BuddyMarkdown.test.tsx
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/buddy/components/BuddyMarkdown.tsx b/src/features/buddy/components/BuddyMarkdown.tsx
index 96d5ceb97..307832b6a 100644
--- a/src/features/buddy/components/BuddyMarkdown.tsx
+++ b/src/features/buddy/components/BuddyMarkdown.tsx
@@ -10,10 +10,11 @@ import remarkGfm from "remark-gfm";
* 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.
+ * 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.startsWith("//");
+ return href !== undefined && href.startsWith("/") && href[1] !== "/" && href[1] !== "\\";
}
/**
diff --git a/src/features/onboarding/buddyDrafts.ts b/src/features/onboarding/buddyDrafts.ts
index 0b3357189..464bddfcc 100644
--- a/src/features/onboarding/buddyDrafts.ts
+++ b/src/features/onboarding/buddyDrafts.ts
@@ -59,6 +59,17 @@ 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?`;
diff --git a/src/features/onboarding/components/StepOriginBadge.tsx b/src/features/onboarding/components/StepOriginBadge.tsx
index 864dab4a8..248e33f8e 100644
--- a/src/features/onboarding/components/StepOriginBadge.tsx
+++ b/src/features/onboarding/components/StepOriginBadge.tsx
@@ -1,4 +1,4 @@
-import { MessageCircle, Sparkles, UserRound } from "lucide-react";
+import { MessageCircle, PenLine, UserRound } from "lucide-react";
import { Badge } from "../../../components/ui/Badge";
import type { OnboardingStepEndpoint } from "../types";
@@ -41,7 +41,7 @@ export function StepOriginBadge({ step, viewer = "hire" }: StepOriginBadgeProps)
if (step.origin === "HIRE") {
return (
-
+
{viewer === "hire" ? "You added this" : "Added by the hire"}
);
diff --git a/src/features/onboarding/components/journey/PhaseItemList.tsx b/src/features/onboarding/components/journey/PhaseItemList.tsx
index 02e368441..26fd8931f 100644
--- a/src/features/onboarding/components/journey/PhaseItemList.tsx
+++ b/src/features/onboarding/components/journey/PhaseItemList.tsx
@@ -20,11 +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 once. `key` is per arrival, so the same link
- * followed twice plays the light twice.
- */
- linkHighlight?: { id: string; key: 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;
@@ -44,7 +43,8 @@ export function PhaseItemList({
phase,
nextItemId,
expandedItemId,
- linkHighlight = null,
+ linkedItemId = null,
+ onLinkHighlightEnd,
onToggle,
onPrimary,
renderExpanded,
@@ -74,11 +74,13 @@ 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 = linkHighlight?.id === item.id;
+ const isLinked = linkedItemId === item.id;
return (
(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),
@@ -135,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");
diff --git a/src/pages/OnBoardingPage.tsx b/src/pages/OnBoardingPage.tsx
index ca9a85cc2..77d7052d4 100644
--- a/src/pages/OnBoardingPage.tsx
+++ b/src/pages/OnBoardingPage.tsx
@@ -73,7 +73,11 @@ 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, askAboutPhase } from "../features/onboarding/buddyDrafts";
+import {
+ askAboutEmptyPhase,
+ askAboutEmptyPhases,
+ askAboutPhase,
+} from "../features/onboarding/buddyDrafts";
import { ApiError } from "../services/apiClient";
import { onboardingGraphService } from "../services/onboardingGraphService";
import { onboardingService } from "../services/onboardingService";
@@ -94,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 = {
@@ -243,9 +250,11 @@ export function OnBoardingPage() {
const [confirmRegenerate, setConfirmRegenerate] = useState(false);
// 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. `key` changes per arrival, so following the same link again
- // restarts the light instead of leaving it spent.
+ // 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);
@@ -383,7 +392,7 @@ export function OnBoardingPage() {
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
@@ -410,10 +419,18 @@ 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") {
- if (path.phases.some((phase) => phase.id === arrival.id)) {
+ const linkedPhase = path.phases.find((phase) => phase.id === arrival.id);
+ if (linkedPhase) {
setSelectedPhaseId(arrival.id);
- setExpandedItemId(null);
+ // 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.",
@@ -441,7 +458,9 @@ export function OnBoardingPage() {
if (arrival.kind === "link-step" || arrival.kind === "link-question") {
setSelectedPhaseId(owningPhase.id);
- setExpandedItemId(null);
+ // "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;
@@ -454,7 +473,7 @@ 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]);
@@ -471,13 +490,24 @@ export function OnBoardingPage() {
);
}, [setSearchParams]);
- // Scrolls to the card a link landed on, once its phase is the one on screen.
+ // 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;
- document
- .getElementById(linkedCardId(linkHighlight.id))
- ?.scrollIntoView?.({ behavior: "smooth", block: "center" });
- }, [linkHighlight, loadingState, selectedPhaseId]);
+ 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(() => {
@@ -536,11 +566,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;
- // Opened to change or withdraw a skip request -- which is what the buddy's link to the step is
- // for -- is not beginning it. The step's own "Start" button still does that.
- if (isSkipPending(item.step.skip)) 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*.
@@ -557,7 +588,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;
});
@@ -614,6 +645,7 @@ export function OnBoardingPage() {
const selectPhase = (phaseId: string) => {
setSelectedPhaseId(phaseId);
setExpandedItemId(null);
+ setLinkHighlight(null);
};
const choosePhase = (phaseId: string) => {
@@ -858,7 +890,7 @@ export function OnBoardingPage() {
something, so it is offered beside the retry rather than instead of it. */}
{generationIssues.length > 0 ? (
issue.title))}
label="Work it out with your buddy instead"
/>
) : null}
@@ -985,7 +1017,8 @@ export function OnBoardingPage() {
phase={selectedPhase}
nextItemId={nextItemId}
expandedItemId={expandedItemId}
- linkHighlight={linkHighlight}
+ linkedItemId={linkHighlight?.id ?? null}
+ onLinkHighlightEnd={clearLinkHighlight}
onToggle={toggleItem}
onPrimary={openItem}
renderExpanded={(item) => renderItemBody(item, "inline")}
@@ -1127,7 +1160,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;
+ const isEmpty = phase.steps.length === 0 && phase.questions.length === 0;
return (
diff --git a/src/styles/index.css b/src/styles/index.css
index b9540faa8..2b878b41a 100644
--- a/src/styles/index.css
+++ b/src/styles/index.css
@@ -964,8 +964,9 @@ body {
}
}
+/* `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 both;
+ 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,
@@ -981,7 +982,7 @@ body {
@media (prefers-reduced-motion: reduce) {
.app-link-highlight {
- animation: app-link-highlight-still 2s step-end 1 both;
+ animation: app-link-highlight-still 2s step-end 1 backwards;
}
}
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/onboarding/buddyDrafts.test.ts b/tests/unit/features/onboarding/buddyDrafts.test.ts
index 3f55c4a54..e97a5b318 100644
--- a/tests/unit/features/onboarding/buddyDrafts.test.ts
+++ b/tests/unit/features/onboarding/buddyDrafts.test.ts
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import {
askAboutEmptyPhase,
+ askAboutEmptyPhases,
askAboutPhase,
askAboutQuestion,
askAboutStep,
@@ -87,6 +88,13 @@ describe("buddy drafts", () => {
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.
diff --git a/tests/unit/features/onboarding/components/journey/StepWorkspace.test.tsx b/tests/unit/features/onboarding/components/journey/StepWorkspace.test.tsx
index 2c2fb2383..4111d7491 100644
--- a/tests/unit/features/onboarding/components/journey/StepWorkspace.test.tsx
+++ b/tests/unit/features/onboarding/components/journey/StepWorkspace.test.tsx
@@ -265,6 +265,31 @@ describe("StepWorkspace", () => {
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();
From c8ac711861f2f5a1c0eba53d01a673ed9aa27fe2 Mon Sep 17 00:00:00 2001
From: DavidLeuter
Date: Wed, 30 Sep 2026 16:39:02 +0200
Subject: [PATCH 20/20] Carry the resolved options of a buddy answer through
the confirm
Backend #261 now resolves a multiple-choice answer to option ids when it
proposes, and checks them at confirm time instead of re-matching the hire's
words (review F2/F4 there). The ids ride the stream as `option_ids` and go
back as `optionIds` on the confirm request.
Co-Authored-By: Claude Opus 5.5
---
.../buddy/hooks/useBuddyConversation.ts | 2 +
src/features/buddy/types.ts | 7 +++
src/services/buddyService.ts | 5 ++
tests/unit/services/buddyService.test.ts | 52 +++++++++++++++++++
4 files changed, 66 insertions(+)
diff --git a/src/features/buddy/hooks/useBuddyConversation.ts b/src/features/buddy/hooks/useBuddyConversation.ts
index 34ed745dc..2a32669f5 100644
--- a/src/features/buddy/hooks/useBuddyConversation.ts
+++ b/src/features/buddy/hooks/useBuddyConversation.ts
@@ -684,6 +684,7 @@ export function useBuddyConversation(
phaseId: proposal.phaseId,
onboardingTaskId: proposal.onboardingTaskId,
answer: proposal.answer,
+ optionIds: proposal.optionIds,
description: proposal.description,
reason: proposal.reason,
waitsOnIds: proposal.waitsOnIds,
@@ -841,6 +842,7 @@ export function useBuddyConversation(
phaseId: action.phaseId,
onboardingTaskId: action.onboardingTaskId,
answer: action.answer,
+ optionIds: action.optionIds,
description: action.description,
reason: action.reason,
waitsOnIds: action.waitsOnIds,
diff --git a/src/features/buddy/types.ts b/src/features/buddy/types.ts
index aa68fab2e..4c9c8978a 100644
--- a/src/features/buddy/types.ts
+++ b/src/features/buddy/types.ts
@@ -166,6 +166,12 @@ export type HireActionProposal = {
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
@@ -361,6 +367,7 @@ export type BuddyStreamHandlers = {
phaseId?: string;
onboardingTaskId?: string;
answer?: string;
+ optionIds?: string[];
description?: string;
reason?: string;
waitsOnIds?: string[];
diff --git a/src/services/buddyService.ts b/src/services/buddyService.ts
index 34b524d52..0aee8f1c5 100644
--- a/src/services/buddyService.ts
+++ b/src/services/buddyService.ts
@@ -140,6 +140,8 @@ interface BuddyStreamChunk {
phase_id?: string;
onboarding_task_id?: string;
answer?: string;
+ /** `answer_question` confirm payload: the options a multiple-choice answer stands for. */
+ option_ids?: string[];
description?: string;
/** `request_skip` confirm payload: the reason that goes to the PM. */
reason?: string;
@@ -206,6 +208,7 @@ export async function performAction(
phaseId?: string;
onboardingTaskId?: string;
answer?: string;
+ optionIds?: string[];
description?: string;
reason?: string;
waitsOnIds?: string[];
@@ -237,6 +240,7 @@ export async function performAction(
phaseId: extras.phaseId,
onboardingTaskId: extras.onboardingTaskId,
answer: extras.answer,
+ optionIds: extras.optionIds,
description: extras.description,
reason: extras.reason,
waitsOnIds: extras.waitsOnIds,
@@ -496,6 +500,7 @@ export async function streamMessage(
phaseId: event.phase_id,
onboardingTaskId: event.onboarding_task_id,
answer: event.answer,
+ optionIds: event.option_ids,
description: event.description,
reason: event.reason,
waitsOnIds: event.waits_on_ids,
diff --git a/tests/unit/services/buddyService.test.ts b/tests/unit/services/buddyService.test.ts
index 9f45f50c3..05abfabf0 100644
--- a/tests/unit/services/buddyService.test.ts
+++ b/tests/unit/services/buddyService.test.ts
@@ -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"],
+ }),
+ );
});
});