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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/features/board/components/BoardGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -471,6 +472,8 @@ function BoardCardView({
return <CurrentTaskCard content={card.content} onCardAdded={onCardAdded} {...props} />;
case "SUGGESTED_TASKS":
return <SuggestedTasksCard content={card.content} onCardAdded={onCardAdded} {...props} />;
case "TASK_POOL":
return <TaskPoolCard content={card.content} {...props} />;
case "COMPETENCY_PROGRESS":
return <CompetencyProgressCard content={card.content} {...props} />;
case "MEMORY_RECAP":
Expand Down
13 changes: 8 additions & 5 deletions src/features/board/components/CurrentTaskCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -69,8 +72,8 @@ export function CurrentTaskCard({
<p className="flex items-start gap-2 rounded-xl bg-app-warning-bg/40 p-3 text-xs text-app-warning-text">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" aria-hidden="true" />
<span>
This issue was closed where it lives. Pick a new task from the &quot;Good next
tasks&quot; card.
This issue was closed where it lives. Grab a new one from the &quot;Task pool&quot;
card, or ask your buddy which would fit.
</span>
</p>
)}
Expand Down Expand Up @@ -98,8 +101,8 @@ export function CurrentTaskCard({
</div>
) : (
<EmptyState size="sm">
Nothing claimed yet. Ask your buddy what would be a good one to pick up — they can suggest
tasks that fit what you&apos;ve already shown.
Nothing grabbed yet. Pick one from the &quot;Task pool&quot; card — or ask your buddy, who
can suggest tasks that fit what you&apos;ve already shown.
</EmptyState>
)}

Expand Down Expand Up @@ -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}
/>
</BoardCardFrame>
);
Expand Down
23 changes: 13 additions & 10 deletions src/features/board/components/SuggestedTasksCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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,
Expand Down Expand Up @@ -72,15 +74,16 @@ export function SuggestedTasksCard({
</a>
)}
</div>
{/* 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. */}
<div className="flex flex-wrap items-center gap-x-3">
<div className="mt-3">
<GrabTaskButton taskId={task.taskId} title={task.title} />
</div>
<AskTheBuddy
question={`I'd like to work on "${task.title}". Can you set that as my goal?`}
label="I want to work on this"
question={`Is "${task.title}" a good fit for me? What would I need to know before I start?`}
label="Is this a good fit?"
/>
<div className="mt-3">
<AddTaskToBoard title={task.title} url={task.url} onAdded={onCardAdded} />
Expand Down
270 changes: 270 additions & 0 deletions src/features/board/components/TaskPoolCard.tsx
Original file line number Diff line number Diff line change
@@ -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<BoardCard, "id" | "owner" | "placedAt">;
onDismiss?: (cardId: string) => void;
dismissing?: boolean;
};

const TYPE_LABELS: Record<TaskType, string> = {
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<TaskType | "ALL">("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 (
<BoardCardFrame
icon={LayoutList}
title="Task pool"
card={card}
subtitle={
tasks.length > 0
? `${tasks.length} open ${tasks.length === 1 ? "task" : "tasks"}, best fit first`
: undefined
}
onDismiss={onDismiss}
dismissing={dismissing}
>
{tasks.length === 0 ? (
<EmptyState size="sm">
Your PM hasn&apos;t put any starter tasks up for this project yet. Your buddy can still
suggest something to get going with.
</EmptyState>
) : (
<div className="space-y-3">
<p className="text-xs text-app-text-muted">
Pick one yourself — or, if you&apos;re not sure what fits, let your buddy help.
</p>
<Input
size="sm"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search tasks"
aria-label="Search tasks"
icon={<Search className="h-4 w-4" aria-hidden="true" />}
/>
<div className="flex flex-wrap items-center gap-1.5">
<FilterChip active={type === "ALL"} onClick={() => setType("ALL")}>
All
</FilterChip>
{presentTypes.map((t) => (
<FilterChip key={t} active={type === t} onClick={() => setType(t)}>
{TYPE_LABELS[t]}
</FilterChip>
))}
<FilterChip active={hideTaken} onClick={() => setHideTaken((v) => !v)}>
Hide taken
</FilterChip>
</div>

{visible.length === 0 ? (
<EmptyState size="sm">No task matches these filters.</EmptyState>
) : (
<ol className="max-h-[28rem] space-y-2 overflow-y-auto pr-1">
{visible.map((task) => (
<PoolTaskRow
key={task.taskId}
task={task}
isCurrent={task.taskId === currentTaskId}
currentTitle={currentTitle}
/>
))}
</ol>
)}
</div>
)}

<AskTheBuddy
question="I'm looking through the task pool and I'm not sure which one fits me. Can you help me pick one?"
label="Not sure? Help me choose"
/>
</BoardCardFrame>
);
}

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;

return (
<li
className={`rounded-xl border p-3 ${
isCurrent ? "border-app-brand-border bg-app-brand-soft/40" : "border-app-border"
}`}
>
<div className="flex items-start justify-between gap-2">
<p className="min-w-0 text-sm font-medium text-app-text">{task.title}</p>
{task.url && (
<a
href={task.url}
target="_blank"
rel="noreferrer"
className="shrink-0 text-app-text-muted transition hover:text-app-text"
aria-label={`Open "${task.title}" where it lives`}
>
<ExternalLink className="h-4 w-4" aria-hidden="true" />
</a>
)}
</div>

<div className="mt-1.5 flex flex-wrap gap-1.5">
{task.bestFit && (
<Badge variant="success" size="sm">
Best fit
</Badge>
)}
{task.taskType !== "OTHER" && (
<Badge variant="neutral" size="sm">
{TYPE_LABELS[task.taskType]}
</Badge>
)}
{task.sourceHasAssignee === true && (
<Badge
variant="warning"
size="sm"
title="The tracker shows somebody assigned. You can still grab it — check with them first."
>
<UserCheck className="mr-1 inline h-3 w-3" aria-hidden="true" />
Someone may be on this
</Badge>
)}
</div>

{task.reasons.length > 0 && (
<ul className="mt-1.5 space-y-0.5">
{(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.
<li key={`${task.taskId}-${index}`} className="text-xs text-app-text-muted">
· {reason}
</li>
))}
</ul>
)}

{expanded && (task.summary || task.rationale) && (
<div className="mt-2 space-y-1.5 border-t border-app-border pt-2 text-xs text-app-text-muted">
{task.summary && <p>{task.summary}</p>}
{task.rationale && (
<p>
<span className="font-medium text-app-text">Why it&apos;s a good first task: </span>
{task.rationale}
</p>
)}
</div>
)}

<div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1.5">
<GrabTaskButton
taskId={task.taskId}
title={task.title}
isCurrent={isCurrent}
currentTitle={currentTitle}
/>
<AskTheBuddy
question={`Is "${task.title}" a good fit for me? What would I need to know before I start?`}
label="Is this a good fit?"
className=""
/>
{hasMore && (
<button
type="button"
onClick={() => setExpanded((v) => !v)}
aria-expanded={expanded}
className="ml-auto inline-flex items-center gap-1 text-xs font-medium text-app-text-muted transition hover:text-app-text"
>
{expanded ? "Less" : "More"}
<ChevronDown
className={`h-3.5 w-3.5 transition-transform ${expanded ? "rotate-180" : ""}`}
aria-hidden="true"
/>
</button>
)}
</div>
</li>
);
}

function FilterChip({
active,
onClick,
children,
}: {
active: boolean;
onClick: () => void;
children: ReactNode;
}) {
return (
<button
type="button"
aria-pressed={active}
onClick={onClick}
className={`rounded-full border px-2.5 py-0.5 text-xs font-medium transition ${
active
? "border-app-brand-border bg-app-brand-soft text-app-brand-text"
: "border-app-border text-app-text-muted hover:text-app-text"
}`}
>
{children}
</button>
);
}
1 change: 1 addition & 0 deletions src/features/board/layout/cardAccents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ const ACCENTS: Record<BoardCardKind, CardAccent> = {

// 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.
Expand Down
2 changes: 2 additions & 0 deletions src/features/board/layout/cardIcons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
CheckSquare,
Footprints,
GitPullRequest,
LayoutList,
Link2,
Network,
PenLine,
Expand Down Expand Up @@ -35,6 +36,7 @@ const ICONS: Record<BoardCardKind, LucideIcon> = {
ARRIVAL_STEPS: PlaneLanding,
OPEN_PULL_REQUESTS: GitPullRequest,
SUGGESTED_TASKS: Sparkles,
TASK_POOL: LayoutList,
COMPETENCY_PROGRESS: Award,
MEMORY_RECAP: Brain,
PATH_STEP: Footprints,
Expand Down
Loading
Loading