diff --git a/src/features/board/components/BoardGrid.tsx b/src/features/board/components/BoardGrid.tsx
index c0b780e2f..8c50816fd 100644
--- a/src/features/board/components/BoardGrid.tsx
+++ b/src/features/board/components/BoardGrid.tsx
@@ -30,6 +30,7 @@ import { OpenPullRequestsCard } from "./OpenPullRequestsCard";
import { PathStepCard } from "./PathStepCard";
import { PathToFirstContributionCard } from "./PathToFirstContributionCard";
import { SuggestedTasksCard } from "./SuggestedTasksCard";
+import { TaskPoolCard } from "./TaskPoolCard";
import { BoardCardContext } from "./boardCardControls";
import { BoardStageBand } from "./BoardStageBand";
import { cardAccent } from "../layout/cardAccents";
@@ -471,6 +472,8 @@ function BoardCardView({
return ;
case "SUGGESTED_TASKS":
return ;
+ case "TASK_POOL":
+ return ;
case "COMPETENCY_PROGRESS":
return ;
case "MEMORY_RECAP":
diff --git a/src/features/board/components/CurrentTaskCard.tsx b/src/features/board/components/CurrentTaskCard.tsx
index 828174296..db71e99d4 100644
--- a/src/features/board/components/CurrentTaskCard.tsx
+++ b/src/features/board/components/CurrentTaskCard.tsx
@@ -31,6 +31,9 @@ type CurrentTaskCardProps = {
* looking current: the task block is kept but muted, a warning stripe above it says what happened
* and where to go next, and the buddy question is repointed at picking a new one. Silently
* dropping the card would leave the hire still thinking this is their goal.
+ *
+ * Picking a task by hand happens on the "Task pool" card, which this one points at whenever there
+ * is something to pick; the buddy is offered beside it, never instead of it.
*/
export function CurrentTaskCard({
content,
@@ -69,8 +72,8 @@ export function CurrentTaskCard({
- This issue was closed where it lives. Pick a new task from the "Good next
- tasks" card.
+ This issue was closed where it lives. Grab a new one from the "Task pool"
+ card, or ask your buddy which would fit.
)}
@@ -98,8 +101,8 @@ export function CurrentTaskCard({
) : (
- Nothing claimed yet. Ask your buddy what would be a good one to pick up — they can suggest
- tasks that fit what you've already shown.
+ Nothing grabbed yet. Pick one from the "Task pool" card — or ask your buddy, who
+ can suggest tasks that fit what you've already shown.
)}
@@ -135,7 +138,7 @@ export function CurrentTaskCard({
? `How do I get started on "${content.title ?? "my task"}"? A short checklist of first steps would help.`
: "What would be a good task for me to pick up?"
}
- label={closed ? "Help me pick another one" : undefined}
+ label={closed || !hasTask ? "Help me choose" : undefined}
/>
);
diff --git a/src/features/board/components/SuggestedTasksCard.tsx b/src/features/board/components/SuggestedTasksCard.tsx
index 2a518bd09..f1c4fedf5 100644
--- a/src/features/board/components/SuggestedTasksCard.tsx
+++ b/src/features/board/components/SuggestedTasksCard.tsx
@@ -5,6 +5,7 @@ import { Marked } from "./Marked";
import { useCardMarks } from "../marks/useCardMarks";
import { AskTheBuddy } from "../../buddy/components/AskTheBuddy";
import { AddTaskToBoard } from "./AddTaskToBoard";
+import { GrabTaskButton } from "../../task-pool/components/GrabTaskButton";
import type { BoardCard, SuggestedTasksContent } from "../types";
type SuggestedTasksCardProps = {
@@ -23,9 +24,10 @@ type SuggestedTasksCardProps = {
* line per signal, and a number is not something a hire can act on. The order already carries
* everything a score would say.
*
- * Claiming is deliberately not a button here. It changes what the hire's whole plan aims at, so it
- * stays where every other onboarding-changing action is: proposed by the buddy, confirmed by the
- * hire, in the conversation.
+ * Grabbing is a button here, with its own confirm step (see `GrabTaskButton`). It used to be
+ * buddy-only — "I want to work on this" opened the conversation and the mentor proposed the claim —
+ * but a hire who already knows what they want should not need a conversation to say so. The buddy
+ * is still one press away, for the hire who wants a second opinion first.
*/
export function SuggestedTasksCard({
content,
@@ -72,15 +74,16 @@ export function SuggestedTasksCard({
)}
- {/* Two different things, and only one of them is a commitment. Saying "I want to
- work on this" aims the hire's whole plan at the task and goes through the
- mentor's confirm button; keeping it is a working copy on their own board that
- claims nothing. A hire who is not ready to claim anything should still be able to
- put a task somewhere they will find it again. */}
+ {/* Grabbing is the commitment — it aims the hire's whole plan at the task. Keeping it
+ is a working copy on their own board that claims nothing, for a hire who is not
+ ready to commit but wants to find the task again. */}
+
+
+
diff --git a/src/features/board/components/TaskPoolCard.tsx b/src/features/board/components/TaskPoolCard.tsx
new file mode 100644
index 000000000..d6307b93e
--- /dev/null
+++ b/src/features/board/components/TaskPoolCard.tsx
@@ -0,0 +1,270 @@
+import { useMemo, useState, type ReactNode } from "react";
+import { ChevronDown, ExternalLink, LayoutList, Search, UserCheck } from "lucide-react";
+import { Badge } from "../../../components/ui/Badge";
+import { EmptyState } from "../../../components/ui/EmptyState";
+import { Input } from "../../../components/ui/Input";
+import { AskTheBuddy } from "../../buddy/components/AskTheBuddy";
+import { GrabTaskButton } from "../../task-pool/components/GrabTaskButton";
+import type { TaskType } from "../../starter-work/types";
+import { BoardCardFrame } from "./BoardCardFrame";
+import type { BoardCard, BoardPoolTask, TaskPoolContent } from "../types";
+
+type TaskPoolCardProps = {
+ content: TaskPoolContent;
+ card: Pick;
+ onDismiss?: (cardId: string) => void;
+ dismissing?: boolean;
+};
+
+const TYPE_LABELS: Record = {
+ BUG: "Bug",
+ FEATURE: "Feature",
+ DOCS: "Docs",
+ TEST: "Tests",
+ CHORE: "Chore",
+ OTHER: "Other",
+};
+
+/**
+ * The whole starter-work pool, to browse and grab from by hand.
+ *
+ * **Browsing is the hire's, help choosing is the buddy's.** "Good next tasks" shows three and the
+ * buddy can reason about fit in conversation — but a task you can only reach by asking somebody is
+ * a task you cannot simply look for. This card is the direct way in: every live task, in the same
+ * fit order the buddy reads, with the same reasons, searchable, filterable, and grabbable here.
+ *
+ * The buddy stays one press away rather than being the only door: "Help me choose" sits above the
+ * list for the hire who does not know where to start, and "Is this a good fit?" sits on every task
+ * for the hire who found a candidate and wants a second opinion.
+ *
+ * The order is the backend's and is never re-sorted here. Filtering hides; it does not re-rank.
+ */
+export function TaskPoolCard({ content, card, onDismiss, dismissing }: TaskPoolCardProps) {
+ const { tasks, currentTaskId } = content;
+ // Handed to every grab button, so "Swap … for this?" names the same task the pool marks — even
+ // when the current-task card is not on the board to read it from. A current task that is no
+ // longer in the live pool leaves it to the button to read the card instead.
+ const currentTitle =
+ currentTaskId === null ? null : tasks.find((task) => task.taskId === currentTaskId)?.title;
+ const [query, setQuery] = useState("");
+ const [type, setType] = useState("ALL");
+ const [hideTaken, setHideTaken] = useState(false);
+
+ const presentTypes = useMemo(
+ () =>
+ (Object.keys(TYPE_LABELS) as TaskType[]).filter((t) => tasks.some((x) => x.taskType === t)),
+ [tasks],
+ );
+ const visible = useMemo(() => {
+ const needle = query.trim().toLowerCase();
+ return tasks.filter((task) => {
+ if (type !== "ALL" && task.taskType !== type) return false;
+ if (hideTaken && task.sourceHasAssignee === true) return false;
+ if (!needle) return true;
+ return `${task.title} ${task.summary ?? ""}`.toLowerCase().includes(needle);
+ });
+ }, [tasks, query, type, hideTaken]);
+
+ return (
+ 0
+ ? `${tasks.length} open ${tasks.length === 1 ? "task" : "tasks"}, best fit first`
+ : undefined
+ }
+ onDismiss={onDismiss}
+ dismissing={dismissing}
+ >
+ {tasks.length === 0 ? (
+
+ Your PM hasn't put any starter tasks up for this project yet. Your buddy can still
+ suggest something to get going with.
+
+ ) : (
+
+
+ Pick one yourself — or, if you're not sure what fits, let your buddy help.
+
+ {task.bestFit && (
+
+ Best fit
+
+ )}
+ {task.taskType !== "OTHER" && (
+
+ {TYPE_LABELS[task.taskType]}
+
+ )}
+ {task.sourceHasAssignee === true && (
+
+
+ Someone may be on this
+
+ )}
+
+
+ {task.reasons.length > 0 && (
+
+ {(expanded ? task.reasons : task.reasons.slice(0, 1)).map((reason, index) => (
+ // By position, not by text: the backend does not promise two reasons never read alike.
+
+ Why it's a good first task:
+ {task.rationale}
+
+ )}
+
+ )}
+
+
+
+
+ {hasMore && (
+
+ )}
+
+
+ );
+}
+
+function FilterChip({
+ active,
+ onClick,
+ children,
+}: {
+ active: boolean;
+ onClick: () => void;
+ children: ReactNode;
+}) {
+ return (
+
+ );
+}
diff --git a/src/features/board/layout/cardAccents.ts b/src/features/board/layout/cardAccents.ts
index 6420bda7b..a4c4bf573 100644
--- a/src/features/board/layout/cardAccents.ts
+++ b/src/features/board/layout/cardAccents.ts
@@ -99,6 +99,7 @@ const ACCENTS: Record = {
// What the board has worked out about the hire: what to try next, how far along they are.
SUGGESTED_TASKS: PURPLE,
+ TASK_POOL: PURPLE,
COMPETENCY_PROGRESS: PURPLE,
// The hire's current step, live from the path — the one AI-owned card the hire partly writes to.
diff --git a/src/features/board/layout/cardIcons.ts b/src/features/board/layout/cardIcons.ts
index bd59bac33..0e9eb9219 100644
--- a/src/features/board/layout/cardIcons.ts
+++ b/src/features/board/layout/cardIcons.ts
@@ -4,6 +4,7 @@ import {
CheckSquare,
Footprints,
GitPullRequest,
+ LayoutList,
Link2,
Network,
PenLine,
@@ -35,6 +36,7 @@ const ICONS: Record = {
ARRIVAL_STEPS: PlaneLanding,
OPEN_PULL_REQUESTS: GitPullRequest,
SUGGESTED_TASKS: Sparkles,
+ TASK_POOL: LayoutList,
COMPETENCY_PROGRESS: Award,
MEMORY_RECAP: Brain,
PATH_STEP: Footprints,
diff --git a/src/features/board/layout/cardNames.ts b/src/features/board/layout/cardNames.ts
index 2d62c4405..74e724a13 100644
--- a/src/features/board/layout/cardNames.ts
+++ b/src/features/board/layout/cardNames.ts
@@ -30,6 +30,8 @@ export function cardName(card: BoardCard): string {
return content.title ?? "The task you are on";
case "SUGGESTED_TASKS":
return "Work worth picking up";
+ case "TASK_POOL":
+ return "Task pool";
case "COMPETENCY_PROGRESS":
return "What you have shown";
case "MEMORY_RECAP":
diff --git a/src/features/board/layout/taskPoolShown.ts b/src/features/board/layout/taskPoolShown.ts
new file mode 100644
index 000000000..63963e7d9
--- /dev/null
+++ b/src/features/board/layout/taskPoolShown.ts
@@ -0,0 +1,50 @@
+/**
+ * Whether the hire has the task pool card on their board.
+ *
+ * The pool card is a switch, not a dismissal. Dismissing a card is sticky on the server — the board
+ * never brings back a card somebody said no to — and for most cards that is the point. The pool is
+ * the one way to grab a task without the buddy, though, so losing it for good to one stray press
+ * would quietly put the hire back to asking. So its X only hides it, and the switch in the board's
+ * rail brings it back, the same way the path strip works (see `pathWindowFold.ts`).
+ *
+ * Local storage for the same reason as the path strip: a preference that follows the machine is
+ * close enough, and it is not worth a round trip. The buddy does not see it.
+ */
+const STORAGE_VERSION = 1;
+
+function shownKey(boardId: string): string {
+ return `sprintstart:board-task-pool-shown:${boardId}`;
+}
+
+type StoredShown = { version: number; shown: unknown };
+
+/** Whether the pool is on this board, defaulting to yes — and to yes for anything unreadable. */
+export function readTaskPoolShown(boardId: string): boolean {
+ if (!boardId) return true;
+
+ try {
+ const raw = window.localStorage.getItem(shownKey(boardId));
+ if (!raw) return true;
+
+ const parsed = JSON.parse(raw) as StoredShown;
+ if (parsed?.version !== STORAGE_VERSION || typeof parsed.shown !== "boolean") return true;
+
+ return parsed.shown;
+ } catch {
+ return true;
+ }
+}
+
+/** Stores whether the pool is on this board. A storage that refuses still holds for this visit. */
+export function writeTaskPoolShown(boardId: string, shown: boolean): void {
+ if (!boardId) return;
+
+ try {
+ window.localStorage.setItem(
+ shownKey(boardId),
+ JSON.stringify({ version: STORAGE_VERSION, shown } satisfies StoredShown),
+ );
+ } catch {
+ // Nothing to do and nothing to say.
+ }
+}
diff --git a/src/features/board/types.ts b/src/features/board/types.ts
index 92d23cf37..ce0153a43 100644
--- a/src/features/board/types.ts
+++ b/src/features/board/types.ts
@@ -1,5 +1,6 @@
import type { ArrivalStep } from "../arrival/types";
import type { StepStatus } from "../onboarding/types";
+import type { TaskType } from "../starter-work/types";
/**
* The board: a hire's persistent working surface on one project.
@@ -15,6 +16,7 @@ export type BoardCardKind =
| "OPEN_PULL_REQUESTS"
| "CURRENT_TASK"
| "SUGGESTED_TASKS"
+ | "TASK_POOL"
| "COMPETENCY_PROGRESS"
| "MEMORY_RECAP"
| "DIAGRAM"
@@ -140,6 +142,32 @@ export type SuggestedTasksContent = {
tasks: BoardSuggestedTask[];
};
+/** One task in the pool, with what a hire needs to choose it. Never a score. */
+export type BoardPoolTask = {
+ taskId: string;
+ title: string;
+ summary: string | null;
+ /** Why this is a reasonable first task, in the words of whoever put it in the pool. */
+ rationale: string | null;
+ url: string | null;
+ taskType: TaskType;
+ reasons: string[];
+ /** Near the top of the ranking and matched on at least one signal. */
+ bestFit: boolean;
+ /** Three-valued: only `true` means the tracker shows somebody on it. */
+ sourceHasAssignee: boolean | null;
+};
+
+/**
+ * The whole live pool, best fit first, to browse and grab from by hand. The order is the ranking —
+ * filter it, never re-sort it. `currentTaskId` is the task the hire is on, if any.
+ */
+export type TaskPoolContent = {
+ kind: "TASK_POOL";
+ tasks: BoardPoolTask[];
+ currentTaskId: string | null;
+};
+
/** One competency, with the bar it is measured against — never a score out of a hundred. */
export type BoardCompetency = {
competencyKey: string;
@@ -326,6 +354,7 @@ export type BoardCardContent =
| OpenPullRequestsContent
| CurrentTaskContent
| SuggestedTasksContent
+ | TaskPoolContent
| CompetencyProgressContent
| MemoryRecapContent
| DiagramContent
diff --git a/src/features/buddy/components/AskTheBuddy.tsx b/src/features/buddy/components/AskTheBuddy.tsx
index e5aab3c77..3c5525b73 100644
--- a/src/features/buddy/components/AskTheBuddy.tsx
+++ b/src/features/buddy/components/AskTheBuddy.tsx
@@ -6,6 +6,11 @@ type AskTheBuddyProps = {
question: string;
/** What the control says. Defaults to a plain invitation. */
label?: string;
+ /**
+ * Spacing around the control. Defaults to the gap it needs under a card's body; a row that lays
+ * its actions out itself passes an empty string.
+ */
+ className?: string;
};
/**
@@ -14,9 +19,10 @@ type AskTheBuddyProps = {
* One mechanism serves every surface that has one. A card can seed a question and the mentor
* answers it with its own tools — which means a card never needs its own action machinery, and
* anything that *would* change the hire's onboarding still arrives as a proposal the hire confirms.
- * Claiming a suggested task is the case that proves it: "I want to work on X" makes the mentor
- * propose `claim_goal`, and the confirm button is still the thing that claims it. A claim button on
- * the card would have gone around that gate.
+ *
+ * Grabbing a task is the one exception with its own button (`task-pool/GrabTaskButton`), because a
+ * hire who already knows what they want should not need a conversation to say so. It keeps a
+ * confirm step of its own, and this stays beside it for the hire who wants help choosing.
*
* The draft is pre-filled rather than sent, so the hire can change it before it goes — it is their
* question, and a card that speaks for somebody is a card they stop trusting.
@@ -32,12 +38,16 @@ type AskTheBuddyProps = {
* The one place `openAiBuddy` really is a no-op is `/buddy`, where the widget takes itself off the
* page: nothing renders this there, and the page's own composer is the thing to use.
*/
-export function AskTheBuddy({ question, label = "Ask your buddy about this" }: AskTheBuddyProps) {
+export function AskTheBuddy({
+ question,
+ label = "Ask your buddy about this",
+ className = "mt-3",
+}: AskTheBuddyProps) {
return (
+ );
+ }
+
+ return (
+
+
+ {replaces ? (
+ <>
+ Swap "{replaces}" for this?
+ >
+ ) : (
+ "Make this your task?"
+ )}
+
+
+ Yes, grab it
+
+ setConfirming(false)}
+ >
+ Cancel
+
+
+ );
+}
diff --git a/src/features/task-pool/hooks/useTaskPool.ts b/src/features/task-pool/hooks/useTaskPool.ts
new file mode 100644
index 000000000..eb7a180ee
--- /dev/null
+++ b/src/features/task-pool/hooks/useTaskPool.ts
@@ -0,0 +1,37 @@
+import { useMutation, useQuery } from "@tanstack/react-query";
+import { myStarterWorkService } from "../../../services/myStarterWorkService";
+import { queryKeys } from "../../../services/queryKeys";
+import { useInvalidateBoard } from "../../board/hooks/useInvalidateBoard";
+import type { Board, CurrentTaskContent } from "../../board/types";
+
+/**
+ * The task the hire is on right now, read off the board already in the cache.
+ *
+ * Never fetches. Every place that grabs a task lives on the board, so the board has been read by
+ * the time this is asked — and the current-task card is the one source the rest of the page
+ * already agrees with. Null when there is no task, or no board loaded yet.
+ */
+export function useCurrentTask(projectId: string): CurrentTaskContent | null {
+ const { data } = useQuery({
+ queryKey: queryKeys.board.byProject(projectId),
+ enabled: false,
+ });
+ const content = data?.cards.find((card) => card.content.kind === "CURRENT_TASK")?.content;
+ return content?.kind === "CURRENT_TASK" && content.taskId !== null ? content : null;
+}
+
+/**
+ * Grabs a pool task as the hire's current task.
+ *
+ * Re-reads this project's board afterwards, whatever happened. On success the current-task card
+ * says something else (or has just been pinned) and the pool's "you're on this" marker has moved.
+ * On a 409 the backend has just checked the task against its source and found it no longer live —
+ * so the pool card is known to be stale, and the task should leave it under the toast that says so.
+ */
+export function useGrabTask(projectId: string) {
+ const invalidateBoard = useInvalidateBoard(projectId);
+ return useMutation({
+ mutationFn: (taskId: string) => myStarterWorkService.claim(projectId, taskId),
+ onSettled: invalidateBoard,
+ });
+}
diff --git a/src/pages/BoardPage.tsx b/src/pages/BoardPage.tsx
index 3990ab8ef..77d2dbd24 100644
--- a/src/pages/BoardPage.tsx
+++ b/src/pages/BoardPage.tsx
@@ -1,11 +1,10 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
-import { Link } from "react-router-dom";
import {
AlertCircle,
- Bot,
Check,
FolderPlus,
LayoutDashboard,
+ LayoutList,
ListTree,
Maximize2,
Milestone,
@@ -42,6 +41,7 @@ import { useToast } from "../context/useToast";
import { useFocusMode } from "../context/useFocusMode";
import { readCollapsedCards, writeCollapsedCards } from "../features/board/layout/collapsedCards";
import { readPathWindowShown, writePathWindowShown } from "../features/board/layout/pathWindowFold";
+import { readTaskPoolShown, writeTaskPoolShown } from "../features/board/layout/taskPoolShown";
import { readPinnedCards, writePinnedCards } from "../features/board/layout/pinnedCards";
import {
ALL_SECTIONS,
@@ -313,6 +313,32 @@ export function BoardPage() {
});
}, [showPathWindow, toast]);
+ // The task pool card, on or off — a switch like the path strip rather than a dismissal. See
+ // `layout/taskPoolShown.ts` for why this one card does not get the sticky server-side removal.
+ const [isTaskPoolShown, setIsTaskPoolShown] = useState(true);
+ const [taskPoolReadFor, setTaskPoolReadFor] = useState(null);
+
+ if (storedFor !== taskPoolReadFor) {
+ setTaskPoolReadFor(storedFor);
+ setIsTaskPoolShown(readTaskPoolShown(boardId));
+ }
+
+ const showTaskPool = useCallback(
+ (shown: boolean) => {
+ setIsTaskPoolShown(shown);
+ writeTaskPoolShown(boardId, shown);
+ },
+ [boardId],
+ );
+
+ const removeTaskPool = useCallback(() => {
+ showTaskPool(false);
+ toast.info("Task pool hidden", {
+ description: "You can put it back from the switches on the right.",
+ action: { label: "Undo", onClick: () => showTaskPool(true) },
+ });
+ }, [showTaskPool, toast]);
+
const [pinnedIds, setPinnedIds] = useState>(new Set());
const [pinsReadFor, setPinsReadFor] = useState(null);
@@ -436,6 +462,11 @@ export function BoardPage() {
}
function handleDismiss(cardId: string) {
+ if (board?.cards.find((card) => card.id === cardId)?.content.kind === "TASK_POOL") {
+ removeTaskPool();
+ return;
+ }
+
setPendingRemovals((current) => new Set(current).add(cardId));
const timer = window.setTimeout(() => {
@@ -474,9 +505,19 @@ export function BoardPage() {
* would be worse than no rail at all — the counts are about the board, and the view is about the
* hire's attention.
*/
+ // Whether the backend put a pool card on this board at all. The rail switch only exists for a
+ // board that has one: a switch that toggles nothing visible reads as broken.
+ const hasTaskPool = board?.cards.some((card) => card.content.kind === "TASK_POOL") ?? false;
+
const allCards = useMemo(
- () => board?.cards.filter((card) => card !== pathCard && !pendingRemovals.has(card.id)) ?? [],
- [board, pathCard, pendingRemovals],
+ () =>
+ board?.cards.filter(
+ (card) =>
+ card !== pathCard &&
+ !pendingRemovals.has(card.id) &&
+ (isTaskPoolShown || card.content.kind !== "TASK_POOL"),
+ ) ?? [],
+ [board, pathCard, pendingRemovals, isTaskPoolShown],
);
const {
@@ -1115,6 +1156,23 @@ export function BoardPage() {
+ {/* The task pool, on or off — the same kind of switch as the path strip above, and
+ for the same reason: the card's own X only hides it, so the way back has to live
+ somewhere the card is not. */}
+ {hasTaskPool && (
+ showTaskPool(!isTaskPoolShown)}
+ aria-pressed={isTaskPoolShown}
+ title={isTaskPoolShown ? "Hide the task pool" : "Show the task pool"}
+ aria-label={isTaskPoolShown ? "Hide the task pool" : "Show the task pool"}
+ >
+
+
+ )}
+
{/* Which cards, by where they came from. It sits below the switches that change the
board's shape because it changes neither the board nor its shape — it only
narrows what is drawn, and it is the one control here that is undone by pressing
@@ -1333,17 +1391,6 @@ export function BoardPage() {
) : null}
-
- {/* The board is curated by the mentor, so it should always be one click from them. */}
-
-
-
- Ask your buddy about any of this
-
-
{
+ return await apiClient.fetch(
+ `${BASE_URL}/goal?projectId=${encodeURIComponent(projectId)}`,
+ { method: "POST", body: JSON.stringify({ taskId }) },
+ );
+ },
+};
diff --git a/tests/unit/features/board/BoardGrid.test.tsx b/tests/unit/features/board/BoardGrid.test.tsx
index 758cea19e..4cebe4af1 100644
--- a/tests/unit/features/board/BoardGrid.test.tsx
+++ b/tests/unit/features/board/BoardGrid.test.tsx
@@ -227,7 +227,7 @@ describe("BoardGrid", () => {
);
// Vanishing when the goal is cleared would read as the board losing things.
- expect(screen.getByText(/nothing claimed yet/i)).toBeInTheDocument();
+ expect(screen.getByText(/nothing grabbed yet/i)).toBeInTheDocument();
});
it("shows why each task was suggested, and never a score", () => {
diff --git a/tests/unit/features/board/BoardSubmenus.test.tsx b/tests/unit/features/board/BoardSubmenus.test.tsx
index 39f2abf60..f7c6ecffd 100644
--- a/tests/unit/features/board/BoardSubmenus.test.tsx
+++ b/tests/unit/features/board/BoardSubmenus.test.tsx
@@ -81,7 +81,7 @@ describe("taking a card into the conversation", () => {
expect(lastDraft()).toContain("no response in 5 days");
});
- it("claiming a suggested task goes through the buddy, not around the confirm gate", () => {
+ it("a suggested task is grabbed behind its own confirm, with the buddy one press away", () => {
render(
{
/>,
);
- // No claim button on the card: claiming changes what the hire's whole plan aims at.
- expect(screen.queryByRole("button", { name: /^claim/i })).not.toBeInTheDocument();
+ // Grabbing changes what the hire's whole plan aims at, so the first press only asks.
+ fireEvent.click(screen.getByRole("button", { name: /grab this/i }));
+ expect(screen.getByRole("button", { name: /yes, grab it/i })).toBeInTheDocument();
- fireEvent.click(screen.getByRole("button", { name: /i want to work on this/i }));
+ fireEvent.click(screen.getByRole("button", { name: /is this a good fit/i }));
expect(lastDraft()).toContain("Fix the flaky login test");
});
diff --git a/tests/unit/features/board/CurrentTaskCard.test.tsx b/tests/unit/features/board/CurrentTaskCard.test.tsx
index f74d39561..fc9492a41 100644
--- a/tests/unit/features/board/CurrentTaskCard.test.tsx
+++ b/tests/unit/features/board/CurrentTaskCard.test.tsx
@@ -48,11 +48,11 @@ function lastDraft(): string {
describe("the current-task card when the issue behind it was closed", () => {
beforeEach(() => vi.mocked(openAiBuddy).mockReset());
- it("shows the warning and points at the good-next-tasks card", () => {
+ it("shows the warning and points at the task pool card", () => {
render();
expect(screen.getByText(/this issue was closed where it lives/i)).toBeInTheDocument();
- expect(screen.getByText(/good next tasks/i)).toBeInTheDocument();
+ expect(screen.getByText(/task pool/i)).toBeInTheDocument();
});
it("keeps the card and the task on screen rather than falling back to the empty state", () => {
@@ -60,13 +60,13 @@ describe("the current-task card when the issue behind it was closed", () => {
expect(screen.getByText("What you're working on")).toBeInTheDocument();
expect(screen.getByText("Fix the flaky login test")).toBeInTheDocument();
- expect(screen.queryByText(/nothing claimed yet/i)).not.toBeInTheDocument();
+ expect(screen.queryByText(/nothing grabbed yet/i)).not.toBeInTheDocument();
});
it("seeds the buddy composer with a question about picking a different task", () => {
render();
- fireEvent.click(screen.getByRole("button", { name: /help me pick another one/i }));
+ fireEvent.click(screen.getByRole("button", { name: /help me choose/i }));
expect(lastDraft()).toContain("Fix the flaky login test");
expect(lastDraft()).toContain("closed");
@@ -95,6 +95,10 @@ describe("the current-task card when the issue behind it was closed", () => {
/>,
);
- expect(screen.getByText(/nothing claimed yet/i)).toBeInTheDocument();
+ expect(screen.getByText(/nothing grabbed yet/i)).toBeInTheDocument();
+ // Both ways in: the pool card, and asking the buddy to help pick.
+ expect(screen.getByText(/task pool/i)).toBeInTheDocument();
+ fireEvent.click(screen.getByRole("button", { name: /help me choose/i }));
+ expect(lastDraft()).toMatch(/good task for me/i);
});
});
diff --git a/tests/unit/features/board/TaskPoolCard.test.tsx b/tests/unit/features/board/TaskPoolCard.test.tsx
new file mode 100644
index 000000000..9170ea9f7
--- /dev/null
+++ b/tests/unit/features/board/TaskPoolCard.test.tsx
@@ -0,0 +1,186 @@
+import { render, screen, fireEvent, waitFor } from "@testing-library/react";
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import type { ReactNode } from "react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { ApiError } from "../../../../src/services/apiClient";
+
+import { TaskPoolCard } from "../../../../src/features/board/components/TaskPoolCard";
+import type {
+ BoardCard,
+ BoardPoolTask,
+ TaskPoolContent,
+} from "../../../../src/features/board/types";
+
+vi.mock("../../../../src/features/projects/useProjectContext", () => ({
+ useProjectContext: () => ({ selectedProjectId: "p1" }),
+}));
+
+vi.mock("../../../../src/features/buddy/aiBuddyBus", () => ({
+ openAiBuddy: vi.fn(),
+}));
+
+vi.mock("../../../../src/services/myStarterWorkService", () => ({
+ myStarterWorkService: { claim: vi.fn() },
+}));
+
+import { openAiBuddy } from "../../../../src/features/buddy/aiBuddyBus";
+import { myStarterWorkService } from "../../../../src/services/myStarterWorkService";
+
+const card: Pick = {
+ id: "c1",
+ owner: "AI",
+ placedAt: null,
+};
+
+function task(taskId: string, title: string, over: Partial = {}): BoardPoolTask {
+ return {
+ taskId,
+ title,
+ summary: null,
+ rationale: null,
+ url: null,
+ taskType: "BUG",
+ reasons: ["You know TypeScript"],
+ bestFit: false,
+ sourceHasAssignee: null,
+ ...over,
+ };
+}
+
+function content(over: Partial = {}): TaskPoolContent {
+ return {
+ kind: "TASK_POOL",
+ tasks: [
+ task("t1", "Fix the flaky login test", { bestFit: true }),
+ task("t2", "Document the release flow", { taskType: "DOCS" }),
+ task("t3", "Add a retry to the uploader", { sourceHasAssignee: true }),
+ ],
+ currentTaskId: null,
+ ...over,
+ };
+}
+
+/** The draft the last "ask the buddy" control put in the composer. */
+function lastDraft(): string {
+ const calls = vi.mocked(openAiBuddy).mock.calls;
+ return calls[calls.length - 1][0]?.draft ?? "";
+}
+
+describe("the task pool card", () => {
+ beforeEach(() => {
+ vi.mocked(openAiBuddy).mockReset();
+ vi.mocked(myStarterWorkService.claim).mockReset();
+ });
+
+ it("lists the whole pool in the order it was ranked", () => {
+ render();
+
+ const titles = screen.getAllByText(/fix the flaky|document the release|add a retry/i);
+ expect(titles.map((el) => el.textContent)).toEqual([
+ "Fix the flaky login test",
+ "Document the release flow",
+ "Add a retry to the uploader",
+ ]);
+ expect(screen.getByText("Best fit")).toBeInTheDocument();
+ });
+
+ it("filters by search, type and whether somebody is already on it", () => {
+ render();
+
+ fireEvent.click(screen.getByRole("button", { name: "Docs" }));
+ expect(screen.queryByText("Fix the flaky login test")).not.toBeInTheDocument();
+ expect(screen.getByText("Document the release flow")).toBeInTheDocument();
+
+ fireEvent.click(screen.getByRole("button", { name: "All" }));
+ fireEvent.click(screen.getByRole("button", { name: "Hide taken" }));
+ expect(screen.queryByText("Add a retry to the uploader")).not.toBeInTheDocument();
+
+ fireEvent.change(screen.getByRole("textbox", { name: /search tasks/i }), {
+ target: { value: "login" },
+ });
+ expect(screen.getByText("Fix the flaky login test")).toBeInTheDocument();
+ expect(screen.queryByText("Document the release flow")).not.toBeInTheDocument();
+ });
+
+ it("grabs only after the second press", async () => {
+ vi.mocked(myStarterWorkService.claim).mockResolvedValue({
+ proposalId: "t1",
+ title: "Fix the flaky login test",
+ summary: null,
+ sourceUrl: null,
+ });
+ render();
+
+ fireEvent.click(screen.getAllByRole("button", { name: /grab this/i })[0]);
+ expect(myStarterWorkService.claim).not.toHaveBeenCalled();
+
+ fireEvent.click(screen.getByRole("button", { name: /yes, grab it/i }));
+ await waitFor(() => expect(myStarterWorkService.claim).toHaveBeenCalledWith("p1", "t1"));
+ });
+
+ it("marks the task the hire is on instead of offering to grab it", () => {
+ render();
+
+ expect(screen.getByText(/you're on this one/i)).toBeInTheDocument();
+ expect(screen.getAllByRole("button", { name: /grab this/i })).toHaveLength(2);
+ });
+
+ it("hands the choice to the buddy when the hire asks for help", () => {
+ render();
+
+ fireEvent.click(screen.getByRole("button", { name: /help me choose/i }));
+ expect(lastDraft()).toMatch(/not sure which one fits/i);
+
+ fireEvent.click(screen.getAllByRole("button", { name: /is this a good fit/i })[1]);
+ expect(lastDraft()).toContain("Document the release flow");
+ });
+
+ it("moves focus to the confirm, and back when it is cancelled", () => {
+ render();
+
+ fireEvent.click(screen.getAllByRole("button", { name: /grab this/i })[0]);
+ const confirm = screen.getByRole("button", { name: /yes, grab it/i });
+ expect(confirm).toHaveFocus();
+ // The question is what a screen reader hears with the button, not a silent change nearby.
+ expect(confirm).toHaveAccessibleDescription(/make this your task/i);
+
+ fireEvent.click(screen.getByRole("button", { name: /cancel/i }));
+ expect(screen.getAllByRole("button", { name: /grab this/i })[0]).toHaveFocus();
+ });
+
+ it("names the task it replaces from the pool itself", () => {
+ // No current-task card in any cache here — the pool alone knows what the hire is on.
+ render();
+
+ fireEvent.click(screen.getAllByRole("button", { name: /grab this/i })[0]);
+
+ expect(screen.getByText(/swap/i)).toHaveTextContent(
+ 'Swap "Document the release flow" for this?',
+ );
+ });
+
+ it("re-reads the board when the task turns out not to be open anymore", async () => {
+ vi.mocked(myStarterWorkService.claim).mockRejectedValue(new ApiError(409, "not live"));
+ const client = new QueryClient({ defaultOptions: { mutations: { retry: false } } });
+ const invalidate = vi.spyOn(client, "invalidateQueries");
+ const wrapper = ({ children }: { children: ReactNode }) => (
+ {children}
+ );
+ render(, { wrapper });
+
+ fireEvent.click(screen.getAllByRole("button", { name: /grab this/i })[0]);
+ fireEvent.click(screen.getByRole("button", { name: /yes, grab it/i }));
+
+ // The backend just checked the task against its source, so the pool card is known to be stale.
+ await waitFor(() => expect(invalidate).toHaveBeenCalledWith({ queryKey: ["board", "p1"] }));
+ // And the hire is handed back the button they started from.
+ expect(screen.getAllByRole("button", { name: /grab this/i })[0]).toHaveFocus();
+ });
+
+ it("says so when the pool is empty, and still offers the buddy", () => {
+ render();
+
+ expect(screen.getByText(/hasn't put any starter tasks up/i)).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: /help me choose/i })).toBeInTheDocument();
+ });
+});
diff --git a/tests/unit/features/board/taskPoolShown.test.ts b/tests/unit/features/board/taskPoolShown.test.ts
new file mode 100644
index 000000000..7e688fcc3
--- /dev/null
+++ b/tests/unit/features/board/taskPoolShown.test.ts
@@ -0,0 +1,54 @@
+import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
+import {
+ readTaskPoolShown,
+ writeTaskPoolShown,
+} from "../../../../src/features/board/layout/taskPoolShown";
+
+describe("whether the task pool card is on the board", () => {
+ beforeEach(() => window.localStorage.clear());
+ afterEach(() => vi.restoreAllMocks());
+
+ it("round-trips the switch", () => {
+ writeTaskPoolShown("b1", false);
+ expect(readTaskPoolShown("b1")).toBe(false);
+
+ writeTaskPoolShown("b1", true);
+ expect(readTaskPoolShown("b1")).toBe(true);
+ });
+
+ it("keeps each board's answer to itself", () => {
+ writeTaskPoolShown("b1", false);
+
+ expect(readTaskPoolShown("b2")).toBe(true);
+ });
+
+ it("shows it to anybody who has not decided otherwise", () => {
+ // A hire who has never seen the pool cannot have decided against it.
+ expect(readTaskPoolShown("b1")).toBe(true);
+ expect(readTaskPoolShown("")).toBe(true);
+ });
+
+ it("shows it rather than trusting something unreadable", () => {
+ // User-writable storage: a hand-edited or foreign entry must not take the pool off a board.
+ window.localStorage.setItem("sprintstart:board-task-pool-shown:b1", "{oh no");
+ window.localStorage.setItem(
+ "sprintstart:board-task-pool-shown:b2",
+ JSON.stringify({ version: 99, shown: false }),
+ );
+
+ expect(readTaskPoolShown("b1")).toBe(true);
+ expect(readTaskPoolShown("b2")).toBe(true);
+ });
+
+ it("survives a storage that throws", () => {
+ vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => {
+ throw new Error("blocked");
+ });
+ vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
+ throw new Error("blocked");
+ });
+
+ expect(() => writeTaskPoolShown("b1", false)).not.toThrow();
+ expect(readTaskPoolShown("b1")).toBe(true);
+ });
+});
diff --git a/tests/unit/pages/BoardPageTaskPool.test.tsx b/tests/unit/pages/BoardPageTaskPool.test.tsx
new file mode 100644
index 000000000..c9ae7eae6
--- /dev/null
+++ b/tests/unit/pages/BoardPageTaskPool.test.tsx
@@ -0,0 +1,136 @@
+import { render, screen, fireEvent, act, waitFor } from "@testing-library/react";
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { MemoryRouter } from "react-router-dom";
+import { BoardPage } from "../../../src/pages/BoardPage";
+import { ToastProvider } from "../../../src/context/ToastProvider";
+import type { Board, BoardCard } from "../../../src/features/board/types";
+
+vi.mock("../../../src/services/boardService", () => ({
+ boardService: { fetchBoard: vi.fn(), dismissCard: vi.fn() },
+}));
+
+vi.mock("../../../src/context/useAuth", () => ({
+ useAuth: () => ({ profile: { permissionGroup: "USER" } }),
+}));
+
+vi.mock("../../../src/features/projects/useProjectContext", async () => {
+ const { createProjectContextValue, createSelectableProject } =
+ await import("../setup/projectContext");
+ return {
+ useProjectContext: () =>
+ createProjectContextValue({
+ selectedProjectId: "p1",
+ projects: [createSelectableProject({ id: "p1", name: "Project One" })],
+ selectedProject: createSelectableProject({ id: "p1", name: "Project One" }),
+ }),
+ };
+});
+
+import { boardService } from "../../../src/services/boardService";
+
+const note: BoardCard = {
+ id: "c1",
+ kind: "NOTE",
+ owner: "HIRE",
+ position: 0,
+ placedAt: null,
+ content: { kind: "NOTE", text: "deploys are on Thursdays" },
+};
+
+const pool: BoardCard = {
+ id: "c2",
+ kind: "TASK_POOL",
+ owner: "AI",
+ position: 1,
+ placedAt: null,
+ content: {
+ kind: "TASK_POOL",
+ currentTaskId: null,
+ tasks: [
+ {
+ taskId: "t1",
+ title: "Fix the flaky login test",
+ summary: null,
+ rationale: null,
+ url: null,
+ taskType: "BUG",
+ reasons: [],
+ bestFit: true,
+ sourceHasAssignee: null,
+ },
+ ],
+ },
+};
+
+function boardWith(...cards: BoardCard[]): Board {
+ return { boardId: "b1", projectId: "p1", cards };
+}
+
+async function renderBoard() {
+ render(
+
+
+
+
+ ,
+ );
+ await waitFor(() =>
+ expect(screen.getByRole("heading", { name: "deploys are on Thursdays" })).toBeInTheDocument(),
+ );
+}
+
+/**
+ * The pool is the one way to grab a task without the buddy, so it is a switch rather than a
+ * dismissal: a server-side dismissal is sticky and neither the hire nor the buddy could undo it.
+ */
+describe("the task pool on the board", () => {
+ beforeEach(() => {
+ window.localStorage.clear();
+ vi.useFakeTimers({ shouldAdvanceTime: true });
+ vi.mocked(boardService.fetchBoard).mockReset().mockResolvedValue(boardWith(note, pool));
+ vi.mocked(boardService.dismissCard).mockReset().mockResolvedValue(undefined);
+ });
+
+ afterEach(() => vi.useRealTimers());
+
+ it("hides on its X and never tells the server", async () => {
+ await renderBoard();
+ expect(screen.getByText("Fix the flaky login test")).toBeInTheDocument();
+
+ fireEvent.click(screen.getByRole("button", { name: /remove the task pool card/i }));
+ expect(screen.queryByText("Fix the flaky login test")).not.toBeInTheDocument();
+
+ // Well past the undo window every other card waits out before it dismisses.
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(8000);
+ });
+ expect(boardService.dismissCard).not.toHaveBeenCalled();
+ });
+
+ it("comes back with Undo", async () => {
+ await renderBoard();
+
+ fireEvent.click(screen.getByRole("button", { name: /remove the task pool card/i }));
+ fireEvent.click(screen.getByRole("button", { name: "Undo" }));
+
+ expect(screen.getByText("Fix the flaky login test")).toBeInTheDocument();
+ });
+
+ it("is switched off and on again from the rail", async () => {
+ await renderBoard();
+
+ fireEvent.click(screen.getByRole("button", { name: "Hide the task pool" }));
+ expect(screen.queryByText("Fix the flaky login test")).not.toBeInTheDocument();
+
+ fireEvent.click(screen.getByRole("button", { name: "Show the task pool" }));
+ expect(screen.getByText("Fix the flaky login test")).toBeInTheDocument();
+ expect(boardService.dismissCard).not.toHaveBeenCalled();
+ });
+
+ it("offers no switch on a board without a pool card", async () => {
+ vi.mocked(boardService.fetchBoard).mockResolvedValue(boardWith(note));
+ await renderBoard();
+
+ expect(screen.queryByRole("button", { name: /the task pool/i })).not.toBeInTheDocument();
+ });
+});