From 48329d0c09b5c9ac5e9bc5e094500658c7a185e2 Mon Sep 17 00:00:00 2001 From: DavidLeuter Date: Sat, 26 Sep 2026 17:06:29 +0200 Subject: [PATCH 1/5] feat(board): browse the task pool and grab a task by hand Until now a hire could only claim a starter task through the buddy. The board now offers the whole live pool in a dialog: ranked by fit, with the same reasons the buddy reads, searchable and filterable by type and by whether somebody is already on it. A task is grabbed with a two-press confirm that names the task it replaces. The buddy stays one press away: "Help me choose" at the top of the dialog and on the empty current-task card, "Is this a good fit?" on every task. Uses the existing GET /me/matches and POST /me/goal; no backend change. Co-Authored-By: Claude Opus 5.5 --- .../board/components/CurrentTaskCard.tsx | 41 ++- .../board/components/SuggestedTasksCard.tsx | 27 +- src/features/buddy/components/AskTheBuddy.tsx | 7 +- .../components/BrowseTasksButton.tsx | 34 ++ .../task-pool/components/GrabTaskButton.tsx | 102 +++++ .../task-pool/components/TaskPoolBrowser.tsx | 348 ++++++++++++++++++ src/features/task-pool/hooks/useTaskPool.ts | 50 +++ src/services/myStarterWorkService.ts | 36 ++ src/services/queryKeys.ts | 2 + tests/unit/features/board/BoardGrid.test.tsx | 2 +- .../features/board/CurrentTaskCard.test.tsx | 22 +- .../task-pool/TaskPoolBrowser.test.tsx | 127 +++++++ 12 files changed, 765 insertions(+), 33 deletions(-) create mode 100644 src/features/task-pool/components/BrowseTasksButton.tsx create mode 100644 src/features/task-pool/components/GrabTaskButton.tsx create mode 100644 src/features/task-pool/components/TaskPoolBrowser.tsx create mode 100644 src/features/task-pool/hooks/useTaskPool.ts create mode 100644 src/services/myStarterWorkService.ts create mode 100644 tests/unit/features/task-pool/TaskPoolBrowser.test.tsx diff --git a/src/features/board/components/CurrentTaskCard.tsx b/src/features/board/components/CurrentTaskCard.tsx index 828174296..982c9ddcc 100644 --- a/src/features/board/components/CurrentTaskCard.tsx +++ b/src/features/board/components/CurrentTaskCard.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 { BrowseTasksButton } from "../../task-pool/components/BrowseTasksButton"; import type { BoardCard, CurrentTaskContent } from "../types"; type CurrentTaskCardProps = { @@ -31,6 +32,10 @@ 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. + * + * The pool is one press away in every state: browsing is how a hire with nothing picks a task, how + * a hire whose task closed picks the next one, and how a hire who changed their mind switches. The + * buddy is offered beside it, never instead of it. */ export function CurrentTaskCard({ content, @@ -69,8 +74,8 @@ export function CurrentTaskCard({

)} @@ -98,8 +103,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. Browse the pool and pick one yourself — or ask your buddy, who can + suggest tasks that fit what you've already shown. )} @@ -127,16 +132,24 @@ export function CurrentTaskCard({ It asks for a checklist, which is not decoration: a task that states no steps of its own gets them here or nowhere, and the offer to keep a reply as a card only appears under a reply that holds a list. See `AddTaskToBoard`. */} - +
+
+ +
+ +
); } diff --git a/src/features/board/components/SuggestedTasksCard.tsx b/src/features/board/components/SuggestedTasksCard.tsx index 2a518bd09..d049cc86a 100644 --- a/src/features/board/components/SuggestedTasksCard.tsx +++ b/src/features/board/components/SuggestedTasksCard.tsx @@ -5,6 +5,8 @@ import { Marked } from "./Marked"; import { useCardMarks } from "../marks/useCardMarks"; import { AskTheBuddy } from "../../buddy/components/AskTheBuddy"; import { AddTaskToBoard } from "./AddTaskToBoard"; +import { BrowseTasksButton } from "../../task-pool/components/BrowseTasksButton"; +import { GrabTaskButton } from "../../task-pool/components/GrabTaskButton"; import type { BoardCard, SuggestedTasksContent } from "../types"; type SuggestedTasksCardProps = { @@ -23,9 +25,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 +75,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. */}
+
+ +
@@ -99,6 +103,9 @@ export function SuggestedTasksCard({ ))} )} +
+ +
); } diff --git a/src/features/buddy/components/AskTheBuddy.tsx b/src/features/buddy/components/AskTheBuddy.tsx index e5aab3c77..df635999a 100644 --- a/src/features/buddy/components/AskTheBuddy.tsx +++ b/src/features/buddy/components/AskTheBuddy.tsx @@ -14,9 +14,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. diff --git a/src/features/task-pool/components/BrowseTasksButton.tsx b/src/features/task-pool/components/BrowseTasksButton.tsx new file mode 100644 index 000000000..2dbec9a1c --- /dev/null +++ b/src/features/task-pool/components/BrowseTasksButton.tsx @@ -0,0 +1,34 @@ +import { useState } from "react"; +import { LayoutList } from "lucide-react"; +import { Button, type ButtonVariant } from "../../../components/ui/Button"; +import { TaskPoolBrowser } from "./TaskPoolBrowser"; + +type BrowseTasksButtonProps = { + label?: string; + variant?: ButtonVariant; +}; + +/** + * Opens the task browser. Owns the dialog, so any card can offer the pool without the page having + * to host it — and the dialog only fetches the pool while it is open. + */ +export function BrowseTasksButton({ + label = "Browse tasks", + variant = "secondary", +}: BrowseTasksButtonProps) { + const [open, setOpen] = useState(false); + + return ( + <> + + setOpen(false)} /> + + ); +} diff --git a/src/features/task-pool/components/GrabTaskButton.tsx b/src/features/task-pool/components/GrabTaskButton.tsx new file mode 100644 index 000000000..ee8b5eeff --- /dev/null +++ b/src/features/task-pool/components/GrabTaskButton.tsx @@ -0,0 +1,102 @@ +import { useState } from "react"; +import { Hand } from "lucide-react"; +import { Button } from "../../../components/ui/Button"; +import { ApiError } from "../../../services/apiClient"; +import { parseApiError } from "../../../services/apiError"; +import { useToastApi } from "../../../context/useToast"; +import { useProjectContext } from "../../projects/useProjectContext"; +import { useCurrentTask, useGrabTask } from "../hooks/useTaskPool"; + +type GrabTaskButtonProps = { + taskId: string; + title: string; + /** Told once the task really is the hire's, e.g. so a dialog around the button can close. */ + onGrabbed?: () => void; +}; + +/** + * Grabbing a task by hand — the same claim the buddy's "Work toward this task" makes. + * + * Two presses, not one. A grab aims the hire's whole plan at the task and replaces whatever they + * were on, so the button first turns into a question that says exactly that ("Swap … for this?") + * and only the second press claims. That keeps the confirm the buddy route always had, without the + * detour through a conversation for somebody who already knows what they want. + */ +export function GrabTaskButton({ taskId, title, onGrabbed }: GrabTaskButtonProps) { + const { selectedProjectId } = useProjectContext(); + const current = useCurrentTask(selectedProjectId); + const grab = useGrabTask(selectedProjectId); + const toast = useToastApi(); + const [confirming, setConfirming] = useState(false); + + if (current?.taskId === taskId) { + return ( + + + ); + } + + const handleConfirm = () => { + grab.mutate(taskId, { + onSuccess: () => { + setConfirming(false); + toast.success(`You're on “${title}” now`, { + description: "It's on your board, and your buddy will shape your next steps around it.", + }); + onGrabbed?.(); + }, + onError: (error) => { + setConfirming(false); + toast.error( + error instanceof ApiError && error.status === 409 + ? "That task just closed where it lives — pick another one." + : parseApiError(error, "Couldn't grab that task. Try again in a moment."), + ); + }, + }); + }; + + if (!confirming) { + return ( + + ); + } + + return ( + + + {current?.title ? ( + <> + Swap “{current.title}” for this? + + ) : ( + "Make this your task?" + )} + + + + + ); +} diff --git a/src/features/task-pool/components/TaskPoolBrowser.tsx b/src/features/task-pool/components/TaskPoolBrowser.tsx new file mode 100644 index 000000000..b30059179 --- /dev/null +++ b/src/features/task-pool/components/TaskPoolBrowser.tsx @@ -0,0 +1,348 @@ +import { useMemo, useState, type ReactNode } from "react"; +import { + ChevronDown, + ExternalLink, + Inbox, + MessageCircle, + Search, + Sparkles, + UserCheck, +} from "lucide-react"; +import { Modal } from "../../../components/ui/Modal"; +import { Badge } from "../../../components/ui/Badge"; +import { Button } from "../../../components/ui/Button"; +import { EmptyState } from "../../../components/ui/EmptyState"; +import { Input } from "../../../components/ui/Input"; +import { SkeletonCard, SkeletonGroup, SkeletonLine } from "../../../components/ui/Skeleton"; +import { openAiBuddy } from "../../buddy/aiBuddyBus"; +import { useProjectContext } from "../../projects/useProjectContext"; +import type { RankedStarterWorkTask, TaskType } from "../../starter-work/types"; +import { useCurrentTask, useTaskPoolMatches } from "../hooks/useTaskPool"; +import { GrabTaskButton } from "./GrabTaskButton"; + +type TaskPoolBrowserProps = { + isOpen: boolean; + onClose: () => void; +}; + +const TYPE_LABELS: Record = { + BUG: "Bug", + FEATURE: "Feature", + DOCS: "Docs", + TEST: "Tests", + CHORE: "Chore", + OTHER: "Other", +}; + +/** How many of the top-ranked tasks earn the "best fit" marker. Mirrors the board's three. */ +const BEST_FIT_COUNT = 3; + +const HELP_ME_CHOOSE = + "I'm looking through the task pool and I'm not sure which one fits me. Can you help me pick one?"; + +/** + * The whole starter-work pool, for a hire picking their own task. + * + * **Browsing is the hire's, choosing help is the buddy's.** The board's "Good next tasks" card + * shows the top 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 is the direct way in: every + * live task, in the same fit order the buddy reads, with the same reasons, searchable and + * filterable, and grabbable right here. + * + * The buddy stays one press away instead of being the only door. "Help me choose" sits at the top, + * above the list, because the hire who needs it is the one who has not scrolled yet; "Is this a good + * fit?" sits on every task, for the hire who has found a candidate and wants a second opinion. + * Both close the dialog first — the buddy opens beside the board, and a question about a task + * reads better with the board behind it than with a dialog on top. + * + * The order is the backend's and is never re-sorted here. Filtering hides; it does not re-rank. + */ +export function TaskPoolBrowser({ isOpen, onClose }: TaskPoolBrowserProps) { + const { selectedProjectId } = useProjectContext(); + const { data, isLoading, isError, refetch } = useTaskPoolMatches(selectedProjectId, isOpen); + const current = useCurrentTask(selectedProjectId); + + const [query, setQuery] = useState(""); + const [type, setType] = useState("ALL"); + const [hideTaken, setHideTaken] = useState(false); + + const tasks = useMemo(() => data ?? [], [data]); + const presentTypes = useMemo( + () => + (Object.keys(TYPE_LABELS) as TaskType[]).filter((t) => tasks.some((m) => m.taskType === t)), + [tasks], + ); + const visible = useMemo(() => { + const needle = query.trim().toLowerCase(); + return tasks.filter((match) => { + if (type !== "ALL" && match.taskType !== type) return false; + if (hideTaken && match.task.sourceHasAssignee === true) return false; + if (!needle) return true; + return [match.task.title, match.task.summary ?? "", ...match.task.competencyKeys] + .join(" ") + .toLowerCase() + .includes(needle); + }); + }, [tasks, query, type, hideTaken]); + + const askBuddy = (draft: string) => { + onClose(); + openAiBuddy({ draft }); + }; + + return ( + +
+ {/* The buddy's way in, first. Somebody unsure is the person least likely to scroll. */} +
+
+ + {tasks.length > 0 && ( +
+ setQuery(event.target.value)} + placeholder="Search by title, summary or skill" + aria-label="Search tasks" + icon={
+ )} + + {isLoading ? ( + + {[0, 1, 2].map((i) => ( + + + + + ))} + + ) : isError ? ( + void refetch()}> + Try again + + } + > + The task pool couldn't be loaded. + + ) : tasks.length === 0 ? ( + + ) : visible.length === 0 ? ( + No task matches these filters. + ) : ( +
    + {visible.map((match) => ( + 0} + isCurrent={current?.taskId === match.task.id} + onGrabbed={onClose} + onAsk={() => + askBuddy( + `Is "${match.task.title}" a good fit for me? What would I need to know before I start?`, + ) + } + /> + ))} +
+ )} +
+
+ ); +} + +type TaskRowProps = { + match: RankedStarterWorkTask; + bestFit: boolean; + isCurrent: boolean; + onGrabbed: () => void; + onAsk: () => void; +}; + +function TaskRow({ match, bestFit, isCurrent, onGrabbed, onAsk }: TaskRowProps) { + const { task, reasons, taskType } = match; + const [expanded, setExpanded] = useState(false); + const hasMore = Boolean(task.summary || task.rationale) || reasons.length > 2; + + return ( +
  • +
    +

    {task.title}

    + {task.sourceUrl && ( + + + )} +
    + +
    + {isCurrent && ( + + Your current task + + )} + {bestFit && ( + + Best fit + + )} + {taskType !== "OTHER" && ( + + {TYPE_LABELS[taskType]} + + )} + {task.sourceHasAssignee === true && ( + + + )} +
    + + {reasons.length > 0 ? ( +
      + {(expanded ? reasons : reasons.slice(0, 2)).map((reason) => ( +
    • + · {reason} +
    • + ))} +
    + ) : ( +

    + · Nothing you've shown so far points at this one — which doesn't rule it out. +

    + )} + + {expanded && ( +
    + {task.summary &&

    {task.summary}

    } + {task.rationale && ( +

    + 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/task-pool/hooks/useTaskPool.ts b/src/features/task-pool/hooks/useTaskPool.ts new file mode 100644 index 000000000..ec50faa2f --- /dev/null +++ b/src/features/task-pool/hooks/useTaskPool.ts @@ -0,0 +1,50 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { myStarterWorkService } from "../../../services/myStarterWorkService"; +import { queryKeys } from "../../../services/queryKeys"; +import type { Board, CurrentTaskContent } from "../../board/types"; + +/** + * The pool, ranked for the signed-in hire. Only fetched while something is showing it: the browser + * is opened on purpose, and a ranking read on every board visit would cost a query nobody looks at. + */ +export function useTaskPoolMatches(projectId: string, enabled: boolean) { + return useQuery({ + queryKey: queryKeys.starterWork.myMatches(projectId), + queryFn: () => myStarterWorkService.fetchMatches(projectId), + enabled: enabled && Boolean(projectId), + }); +} + +/** + * 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 the board and the ranking afterwards: the current-task card now says something else, + * and the pool's "you're on this" marker has moved. + */ +export function useGrabTask(projectId: string) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (taskId: string) => myStarterWorkService.claim(projectId, taskId), + onSuccess: () => + Promise.all([ + queryClient.invalidateQueries({ queryKey: queryKeys.board.all() }), + queryClient.invalidateQueries({ queryKey: queryKeys.starterWork.myMatches(projectId) }), + ]), + }); +} diff --git a/src/services/myStarterWorkService.ts b/src/services/myStarterWorkService.ts new file mode 100644 index 000000000..9ebf31dd8 --- /dev/null +++ b/src/services/myStarterWorkService.ts @@ -0,0 +1,36 @@ +import { apiClient } from "./apiClient"; +import type { Goal, RankedStarterWorkTask } from "../features/starter-work/types"; + +const BASE_URL = "/api/v1/onboarding/starter-work/me"; + +/** + * The hire's side of the starter-work pool: browsing it and grabbing a task from it. + * + * Kept apart from `starterWorkService`, which is the PM's side. Both endpoints existed before this + * client did — the buddy's `claim_goal` action ends in the same `POST /me/goal` — so grabbing by + * hand and grabbing through the buddy land in exactly the same place. + */ +export const myStarterWorkService = { + /** + * Every live task this hire may take on the project, best fit first, each with the reasons it + * ranks where it does. Deterministic and local on the backend — no model call. + */ + async fetchMatches(projectId: string): Promise { + return await apiClient.fetch( + `${BASE_URL}/matches?projectId=${encodeURIComponent(projectId)}`, + ); + }, + + /** + * Makes a pool task the hire's current task, replacing whatever they were on. + * + * Fails with 409 when the task closed at its source since the pool last looked — the backend + * checks the tracker at this moment rather than trusting the pool's last pass. + */ + async claim(projectId: string, taskId: string): Promise { + return await apiClient.fetch( + `${BASE_URL}/goal?projectId=${encodeURIComponent(projectId)}`, + { method: "POST", body: JSON.stringify({ taskId }) }, + ); + }, +}; diff --git a/src/services/queryKeys.ts b/src/services/queryKeys.ts index 74c2f2093..3fbdbe2a8 100644 --- a/src/services/queryKeys.ts +++ b/src/services/queryKeys.ts @@ -36,6 +36,8 @@ export const queryKeys = { corpusIssues: (projectId: string) => ["starter-work", "corpus", projectId] as const, taskOrientation: (taskId: string, projectId: string) => ["starter-work", "orientation", taskId, projectId] as const, + /** The live pool ranked for the signed-in hire — the task browser's read. */ + myMatches: (projectId: string) => ["starter-work", "my-matches", projectId] as const, }, profile: { mine: (userId: string) => ["profile", userId] as const, 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/CurrentTaskCard.test.tsx b/tests/unit/features/board/CurrentTaskCard.test.tsx index f74d39561..06cf9f86a 100644 --- a/tests/unit/features/board/CurrentTaskCard.test.tsx +++ b/tests/unit/features/board/CurrentTaskCard.test.tsx @@ -14,6 +14,12 @@ vi.mock("../../../../src/features/board/marks/useCardMarks", () => ({ }), })); +// The card offers the task browser, which reads the selected project — stood in, as in +// `BoardGrid.test.tsx`, rather than wrapping the card in a provider. +vi.mock("../../../../src/features/projects/useProjectContext", () => ({ + useProjectContext: () => ({ selectedProjectId: "p1" }), +})); + vi.mock("../../../../src/features/buddy/aiBuddyBus", () => ({ openAiBuddy: vi.fn(), })); @@ -48,11 +54,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", () => { render(); expect(screen.getByText(/this issue was closed where it lives/i)).toBeInTheDocument(); - expect(screen.getByText(/good next tasks/i)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /browse tasks/i })).toBeInTheDocument(); }); it("keeps the card and the task on screen rather than falling back to the empty state", () => { @@ -60,13 +66,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"); @@ -80,6 +86,8 @@ describe("the current-task card when the issue behind it was closed", () => { fireEvent.click(screen.getByRole("button", { name: /ask your buddy about this/i })); expect(lastDraft()).toMatch(/how do I get started/i); + // Somebody already on a task can still change their mind without asking anyone. + expect(screen.getByRole("button", { name: /switch task/i })).toBeInTheDocument(); }); it("still shows the empty state when there is no task, closedAtSource notwithstanding", () => { @@ -95,6 +103,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: browsing yourself, and asking the buddy to help pick. + expect(screen.getByRole("button", { name: /browse tasks/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/task-pool/TaskPoolBrowser.test.tsx b/tests/unit/features/task-pool/TaskPoolBrowser.test.tsx new file mode 100644 index 000000000..1e0f2d6f5 --- /dev/null +++ b/tests/unit/features/task-pool/TaskPoolBrowser.test.tsx @@ -0,0 +1,127 @@ +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; + +import { TaskPoolBrowser } from "../../../../src/features/task-pool/components/TaskPoolBrowser"; +import type { RankedStarterWorkTask } from "../../../../src/features/starter-work/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: { + fetchMatches: vi.fn(), + claim: vi.fn(), + }, +})); + +import { openAiBuddy } from "../../../../src/features/buddy/aiBuddyBus"; +import { myStarterWorkService } from "../../../../src/services/myStarterWorkService"; + +function match( + id: string, + title: string, + over: Partial = {}, + task: Partial = {}, +): RankedStarterWorkTask { + return { + task: { + id, + sourceId: `gh-${id}`, + title, + summary: null, + rationale: null, + sourceUrl: null, + competencyKeys: [], + status: "LIVE", + reviewed: true, + taskZeroEligible: false, + sourceHasAssignee: null, + sourceCheckedAt: null, + ...task, + }, + score: 1, + matchedCompetencyKeys: [], + taskType: "BUG", + reasons: ["You know TypeScript"], + ...over, + }; +} + +describe("the task browser", () => { + beforeEach(() => { + vi.mocked(openAiBuddy).mockReset(); + vi.mocked(myStarterWorkService.claim).mockReset(); + vi.mocked(myStarterWorkService.fetchMatches).mockResolvedValue([ + match("t1", "Fix the flaky login test"), + match("t2", "Document the release flow", { taskType: "DOCS" }), + match("t3", "Add a retry to the uploader", {}, { sourceHasAssignee: true }), + ]); + }); + + it("lists the whole pool in the order it was ranked", async () => { + render( {}} />); + + const titles = await screen.findAllByText(/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", + ]); + }); + + it("filters by search, type and whether somebody is already on it", async () => { + render( {}} />); + await screen.findByText("Fix the flaky login test"); + + 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, then closes", async () => { + vi.mocked(myStarterWorkService.claim).mockResolvedValue({ + proposalId: "t1", + title: "Fix the flaky login test", + summary: null, + sourceUrl: null, + }); + const onClose = vi.fn(); + render(); + await screen.findByText("Fix the flaky login test"); + + 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")); + await waitFor(() => expect(onClose).toHaveBeenCalled()); + }); + + it("hands the choice to the buddy when the hire asks for help", async () => { + const onClose = vi.fn(); + render(); + await screen.findByText("Fix the flaky login test"); + + fireEvent.click(screen.getByRole("button", { name: /help me choose/i })); + expect(onClose).toHaveBeenCalled(); + expect(vi.mocked(openAiBuddy).mock.calls[0][0]?.draft).toMatch(/not sure which one fits/i); + + fireEvent.click(screen.getAllByRole("button", { name: /is this a good fit/i })[1]); + expect(vi.mocked(openAiBuddy).mock.calls[1][0]?.draft).toContain("Document the release flow"); + }); +}); From 2094c75e6ee17809c415f3ad7b8d200756288ffa Mon Sep 17 00:00:00 2001 From: DavidLeuter Date: Sat, 26 Sep 2026 17:42:36 +0200 Subject: [PATCH 2/5] feat(board): task pool as its own board card instead of a dialog Replaces the browse dialog with a TASK_POOL card rendered from the board's own read: the whole pool best fit first, search, type filter, "hide taken", grab with a two-press confirm, and the buddy one press away ("Help me choose", "Is this a good fit?"). The current-task and suggested-tasks cards point at the pool card rather than opening anything. Grabbing re-reads the board only; the separate /me/matches read is gone. Co-Authored-By: Claude Opus 5.5 --- src/features/board/components/BoardGrid.tsx | 3 + .../board/components/CurrentTaskCard.tsx | 42 +-- .../board/components/SuggestedTasksCard.tsx | 4 - .../board/components/TaskPoolCard.tsx | 266 +++++++++++++ src/features/board/layout/cardAccents.ts | 1 + src/features/board/layout/cardIcons.ts | 2 + src/features/board/layout/cardNames.ts | 2 + src/features/board/types.ts | 29 ++ .../components/BrowseTasksButton.tsx | 34 -- .../task-pool/components/GrabTaskButton.tsx | 6 +- .../task-pool/components/TaskPoolBrowser.tsx | 348 ------------------ src/features/task-pool/hooks/useTaskPool.ts | 22 +- src/services/myStarterWorkService.ts | 21 +- src/services/queryKeys.ts | 2 - .../features/board/BoardSubmenus.test.tsx | 9 +- .../features/board/CurrentTaskCard.test.tsx | 16 +- .../TaskPoolCard.test.tsx} | 120 +++--- 17 files changed, 408 insertions(+), 519 deletions(-) create mode 100644 src/features/board/components/TaskPoolCard.tsx delete mode 100644 src/features/task-pool/components/BrowseTasksButton.tsx delete mode 100644 src/features/task-pool/components/TaskPoolBrowser.tsx rename tests/unit/features/{task-pool/TaskPoolBrowser.test.tsx => board/TaskPoolCard.test.tsx} (50%) 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 982c9ddcc..db71e99d4 100644 --- a/src/features/board/components/CurrentTaskCard.tsx +++ b/src/features/board/components/CurrentTaskCard.tsx @@ -5,7 +5,6 @@ import { Marked } from "./Marked"; import { useCardMarks } from "../marks/useCardMarks"; import { AskTheBuddy } from "../../buddy/components/AskTheBuddy"; import { AddTaskToBoard } from "./AddTaskToBoard"; -import { BrowseTasksButton } from "../../task-pool/components/BrowseTasksButton"; import type { BoardCard, CurrentTaskContent } from "../types"; type CurrentTaskCardProps = { @@ -33,9 +32,8 @@ type CurrentTaskCardProps = { * 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. * - * The pool is one press away in every state: browsing is how a hire with nothing picks a task, how - * a hire whose task closed picks the next one, and how a hire who changed their mind switches. The - * buddy is offered beside it, never instead of it. + * 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, @@ -74,8 +72,8 @@ export function CurrentTaskCard({

    )} @@ -103,8 +101,8 @@ export function CurrentTaskCard({
    ) : ( - Nothing grabbed yet. Browse the pool and pick one yourself — or ask your buddy, who 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. )} @@ -132,24 +130,16 @@ export function CurrentTaskCard({ It asks for a checklist, which is not decoration: a task that states no steps of its own gets them here or nowhere, and the offer to keep a reply as a card only appears under a reply that holds a list. See `AddTaskToBoard`. */} -
    -
    - -
    - -
    + ); } diff --git a/src/features/board/components/SuggestedTasksCard.tsx b/src/features/board/components/SuggestedTasksCard.tsx index d049cc86a..f1c4fedf5 100644 --- a/src/features/board/components/SuggestedTasksCard.tsx +++ b/src/features/board/components/SuggestedTasksCard.tsx @@ -5,7 +5,6 @@ import { Marked } from "./Marked"; import { useCardMarks } from "../marks/useCardMarks"; import { AskTheBuddy } from "../../buddy/components/AskTheBuddy"; import { AddTaskToBoard } from "./AddTaskToBoard"; -import { BrowseTasksButton } from "../../task-pool/components/BrowseTasksButton"; import { GrabTaskButton } from "../../task-pool/components/GrabTaskButton"; import type { BoardCard, SuggestedTasksContent } from "../types"; @@ -103,9 +102,6 @@ export function SuggestedTasksCard({ ))} )} -
    - -
    ); } diff --git a/src/features/board/components/TaskPoolCard.tsx b/src/features/board/components/TaskPoolCard.tsx new file mode 100644 index 000000000..f8712698d --- /dev/null +++ b/src/features/board/components/TaskPoolCard.tsx @@ -0,0 +1,266 @@ +import { useMemo, useState, type ReactNode } from "react"; +import { + ChevronDown, + ExternalLink, + LayoutList, + MessageCircle, + 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 { openAiBuddy } from "../../buddy/aiBuddyBus"; +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; + 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. +

    + setQuery(event.target.value)} + placeholder="Search tasks" + aria-label="Search tasks" + icon={
    + )} + + +
    + ); +} + +function PoolTaskRow({ task, isCurrent }: { task: BoardPoolTask; isCurrent: boolean }) { + const [expanded, setExpanded] = useState(false); + const hasMore = Boolean(task.summary || task.rationale) || task.reasons.length > 1; + + return ( +
  • +
    +

    {task.title}

    + {task.url && ( + + + )} +
    + +
    + {task.bestFit && ( + + Best fit + + )} + {task.taskType !== "OTHER" && ( + + {TYPE_LABELS[task.taskType]} + + )} + {task.sourceHasAssignee === true && ( + + + )} +
    + + {task.reasons.length > 0 && ( +
      + {(expanded ? task.reasons : task.reasons.slice(0, 1)).map((reason) => ( +
    • + · {reason} +
    • + ))} +
    + )} + + {expanded && (task.summary || task.rationale) && ( +
    + {task.summary &&

    {task.summary}

    } + {task.rationale && ( +

    + 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/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/task-pool/components/BrowseTasksButton.tsx b/src/features/task-pool/components/BrowseTasksButton.tsx deleted file mode 100644 index 2dbec9a1c..000000000 --- a/src/features/task-pool/components/BrowseTasksButton.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { useState } from "react"; -import { LayoutList } from "lucide-react"; -import { Button, type ButtonVariant } from "../../../components/ui/Button"; -import { TaskPoolBrowser } from "./TaskPoolBrowser"; - -type BrowseTasksButtonProps = { - label?: string; - variant?: ButtonVariant; -}; - -/** - * Opens the task browser. Owns the dialog, so any card can offer the pool without the page having - * to host it — and the dialog only fetches the pool while it is open. - */ -export function BrowseTasksButton({ - label = "Browse tasks", - variant = "secondary", -}: BrowseTasksButtonProps) { - const [open, setOpen] = useState(false); - - return ( - <> - - setOpen(false)} /> - - ); -} diff --git a/src/features/task-pool/components/GrabTaskButton.tsx b/src/features/task-pool/components/GrabTaskButton.tsx index ee8b5eeff..13c180199 100644 --- a/src/features/task-pool/components/GrabTaskButton.tsx +++ b/src/features/task-pool/components/GrabTaskButton.tsx @@ -10,6 +10,8 @@ import { useCurrentTask, useGrabTask } from "../hooks/useTaskPool"; type GrabTaskButtonProps = { taskId: string; title: string; + /** Whether this is already the hire's task. Read off the board when not given. */ + isCurrent?: boolean; /** Told once the task really is the hire's, e.g. so a dialog around the button can close. */ onGrabbed?: () => void; }; @@ -22,14 +24,14 @@ type GrabTaskButtonProps = { * and only the second press claims. That keeps the confirm the buddy route always had, without the * detour through a conversation for somebody who already knows what they want. */ -export function GrabTaskButton({ taskId, title, onGrabbed }: GrabTaskButtonProps) { +export function GrabTaskButton({ taskId, title, isCurrent, onGrabbed }: GrabTaskButtonProps) { const { selectedProjectId } = useProjectContext(); const current = useCurrentTask(selectedProjectId); const grab = useGrabTask(selectedProjectId); const toast = useToastApi(); const [confirming, setConfirming] = useState(false); - if (current?.taskId === taskId) { + if (isCurrent ?? current?.taskId === taskId) { return (
    ) : null} - - {/* The board is curated by the mentor, so it should always be one click from them. */} -

    - -

    Date: Tue, 29 Sep 2026 16:45:10 +0200 Subject: [PATCH 5/5] =?UTF-8?q?fix(task-pool):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20stale=20409,=20focus,=20rail=20switch,=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Re-read the project's board after every grab attempt (onSettled via useInvalidateBoard), so a task that turned out not to be open leaves the pool under the toast saying so. 409 copy no longer claims the task "closed where it lives" — it covers retired/removed too. - Move focus to "Yes, grab it" when the confirm appears (described by the question) and back to "Grab this" on cancel or failure. - Render the rail switch only on a board that has a TASK_POOL card. - The pool card hands the current task's title to its grab buttons, so "Swap ... for this?" agrees with "You're on this one" even without a current-task card on the board. - AskTheBuddy takes a className; the pool card uses it instead of calling openAiBuddy directly. Reasons keyed by position. Straight quotes in toast and confirm copy. - Tests: taskPoolShown storage; BoardPage X hides without dismissing, Undo, rail switch, no switch without a pool card; focus handling; 409 re-reads the board. Co-Authored-By: Claude Opus 5.5 --- .../board/components/TaskPoolCard.tsx | 54 +++---- src/features/buddy/components/AskTheBuddy.tsx | 13 +- .../task-pool/components/GrabTaskButton.tsx | 60 ++++++-- src/features/task-pool/hooks/useTaskPool.ts | 13 +- src/pages/BoardPage.tsx | 29 ++-- .../unit/features/board/TaskPoolCard.test.tsx | 45 ++++++ .../unit/features/board/taskPoolShown.test.ts | 54 +++++++ tests/unit/pages/BoardPageTaskPool.test.tsx | 136 ++++++++++++++++++ 8 files changed, 350 insertions(+), 54 deletions(-) create mode 100644 tests/unit/features/board/taskPoolShown.test.ts create mode 100644 tests/unit/pages/BoardPageTaskPool.test.tsx diff --git a/src/features/board/components/TaskPoolCard.tsx b/src/features/board/components/TaskPoolCard.tsx index f8712698d..d6307b93e 100644 --- a/src/features/board/components/TaskPoolCard.tsx +++ b/src/features/board/components/TaskPoolCard.tsx @@ -1,17 +1,9 @@ import { useMemo, useState, type ReactNode } from "react"; -import { - ChevronDown, - ExternalLink, - LayoutList, - MessageCircle, - Search, - UserCheck, -} from "lucide-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 { openAiBuddy } from "../../buddy/aiBuddyBus"; import { GrabTaskButton } from "../../task-pool/components/GrabTaskButton"; import type { TaskType } from "../../starter-work/types"; import { BoardCardFrame } from "./BoardCardFrame"; @@ -49,6 +41,11 @@ const TYPE_LABELS: Record = { */ 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); @@ -122,6 +119,7 @@ export function TaskPoolCard({ content, card, onDismiss, dismissing }: TaskPoolC key={task.taskId} task={task} isCurrent={task.taskId === currentTaskId} + currentTitle={currentTitle} /> ))} @@ -137,7 +135,14 @@ export function TaskPoolCard({ content, card, onDismiss, dismissing }: TaskPoolC ); } -function PoolTaskRow({ task, isCurrent }: { task: BoardPoolTask; isCurrent: boolean }) { +type PoolTaskRowProps = { + task: BoardPoolTask; + isCurrent: boolean; + /** See `GrabTaskButton.currentTitle` — undefined means "not known here". */ + currentTitle: string | null | undefined; +}; + +function PoolTaskRow({ task, isCurrent, currentTitle }: PoolTaskRowProps) { const [expanded, setExpanded] = useState(false); const hasMore = Boolean(task.summary || task.rationale) || task.reasons.length > 1; @@ -187,8 +192,9 @@ function PoolTaskRow({ task, isCurrent }: { task: BoardPoolTask; isCurrent: bool {task.reasons.length > 0 && (
      - {(expanded ? task.reasons : task.reasons.slice(0, 1)).map((reason) => ( -
    • + {(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. +
    • · {reason}
    • ))} @@ -208,19 +214,17 @@ function PoolTaskRow({ task, isCurrent }: { task: BoardPoolTask; isCurrent: bool )}
      - - + + {hasMore && ( + {hasTaskPool && ( + + )} {/* 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 diff --git a/tests/unit/features/board/TaskPoolCard.test.tsx b/tests/unit/features/board/TaskPoolCard.test.tsx index 18f6887fa..9170ea9f7 100644 --- a/tests/unit/features/board/TaskPoolCard.test.tsx +++ b/tests/unit/features/board/TaskPoolCard.test.tsx @@ -1,5 +1,8 @@ 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 { @@ -132,6 +135,48 @@ describe("the task pool card", () => { 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(); 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(); + }); +});