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
6 changes: 3 additions & 3 deletions apps/mcp/src/__tests__/utils/formatters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ describe("formatTask", () => {
const result = formatTask(baseTask);
expect(result).toContain("Status: None");
expect(result).toContain("Sprint: None");
expect(result).toContain("Assignee: Unassigned");
expect(result).toContain("Assignees: Unassigned");
expect(result).toContain("Parent Task: None");
expect(result).toContain("Story Points: None");
expect(result).toContain("Tags: None");
Expand Down Expand Up @@ -403,12 +403,12 @@ describe("formatTaskDetail", () => {
role_name: "Developer",
};
const result = formatTaskDetail(
{ ...baseTask, assignee_id: "u1" },
{ ...baseTask, assignee_ids: ["u1"] },
undefined,
undefined,
undefined,
undefined,
assignee,
[assignee],
);
expect(result).toContain("Alice Smith");
expect(result).toContain("@alice");
Expand Down
6 changes: 4 additions & 2 deletions apps/mcp/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,8 @@ export class PacaAPIClient {
if (input.task_type_id !== undefined)
body.task_type_id = input.task_type_id;
if (input.sprint_id !== undefined) body.sprint_id = input.sprint_id;
if (input.assignee_id !== undefined) body.assignee_id = input.assignee_id;
if (input.assignee_id !== undefined)
body.assignee_ids = input.assignee_id ? [input.assignee_id] : [];
if (input.parent_task_id !== undefined)
body.parent_task_id = input.parent_task_id;
if (input.importance !== undefined) body.importance = input.importance;
Expand Down Expand Up @@ -237,7 +238,8 @@ export class PacaAPIClient {
if (input.task_type_id !== undefined)
body.task_type_id = input.task_type_id;
if (input.sprint_id !== undefined) body.sprint_id = input.sprint_id;
if (input.assignee_id !== undefined) body.assignee_id = input.assignee_id;
if (input.assignee_id !== undefined)
body.assignee_ids = input.assignee_id ? [input.assignee_id] : [];
if (input.reporter_id !== undefined) body.reporter_id = input.reporter_id;
if (input.parent_task_id !== undefined)
body.parent_task_id = input.parent_task_id;
Expand Down
6 changes: 4 additions & 2 deletions apps/mcp/src/tools/task-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -548,7 +548,9 @@ async function getTaskDetail(
const status = statuses.find((s: any) => s.id === task.status_id);
const taskType = taskTypes.find((t: any) => t.id === task.task_type_id);
const sprint = sprints.find((s: any) => s.id === task.sprint_id);
const assignee = members.find((m: any) => m.id === task.assignee_id);
const assignees = members.filter((m: any) =>
task.assignee_ids?.includes(m.id),
);
const reporter = members.find((m: any) => m.id === task.reporter_id);
let parentTask: Task | undefined;
if (task.parent_task_id) {
Expand All @@ -565,7 +567,7 @@ async function getTaskDetail(
status,
taskType,
sprint,
assignee,
assignees,
reporter,
parentTask,
subtasks,
Expand Down
2 changes: 1 addition & 1 deletion apps/mcp/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ export interface Task {
description?: unknown[] | null;
importance: number;
story_points?: number | null;
assignee_id?: string | null;
assignee_ids?: string[];
reporter_id?: string | null;
custom_fields: Record<string, unknown>;
start_date?: string | null;
Expand Down
20 changes: 12 additions & 8 deletions apps/mcp/src/utils/formatters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ ID: ${task.id}
Status: ${task.status_id || "None"}
Type: ${task.task_type_id || "None"}
Sprint: ${task.sprint_id || "None"}
Assignee: ${task.assignee_id || "Unassigned"}
Assignees: ${task.assignee_ids && task.assignee_ids.length > 0 ? task.assignee_ids.join(", ") : "Unassigned"}
Parent Task: ${task.parent_task_id || "None"}
Importance: ${task.importance}
Story Points: ${task.story_points ?? "None"}
Expand All @@ -77,7 +77,7 @@ ${description}`;
* @param status - The task status object (if available)
* @param taskType - The task type object (if available)
* @param sprint - The sprint object (if available)
* @param assignee - The assignee member object (if available)
* @param assignees - The assignee member objects (if available)
* @param reporter - The reporter member object (if available)
* @param parentTask - The parent task object (if available)
* @param subtasks - Array of subtasks (if available)
Expand All @@ -100,7 +100,7 @@ export function formatTaskDetail(
status?: TaskStatus,
taskType?: TaskType,
sprint?: Sprint,
assignee?: ProjectMember,
assignees?: ProjectMember[],
reporter?: ProjectMember,
parentTask?: Task,
subtasks?: Task[],
Expand Down Expand Up @@ -141,10 +141,14 @@ export function formatTaskDetail(
);
}

if (assignee || task.assignee_id) {
sections.push(
`**Assignee:** ${assignee ? `${assignee.full_name || assignee.username} (@${assignee.username})` : task.assignee_id || "Unassigned"}`,
);
if ((assignees && assignees.length > 0) || (task.assignee_ids && task.assignee_ids.length > 0)) {
const label =
assignees && assignees.length > 0
? assignees
.map((a) => `${a.full_name || a.username} (@${a.username})`)
.join(", ")
: (task.assignee_ids ?? []).join(", ") || "Unassigned";
sections.push(`**Assignees:** ${label}`);
}

if (reporter || task.reporter_id) {
Expand Down Expand Up @@ -200,7 +204,7 @@ export function formatTaskDetail(
sections.push("## Subtasks");
subtasks.forEach((subtask, index) => {
sections.push(
`${index + 1}. **${subtask.title}** (#${subtask.task_number}) - Status ID: ${subtask.status_id || "None"}, Type ID: ${subtask.task_type_id || "None"}, Assignee ID: ${subtask.assignee_id || "Unassigned"}`,
`${index + 1}. **${subtask.title}** (#${subtask.task_number}) - Status ID: ${subtask.status_id || "None"}, Type ID: ${subtask.task_type_id || "None"}, Assignee IDs: ${subtask.assignee_ids && subtask.assignee_ids.length > 0 ? subtask.assignee_ids.join(", ") : "Unassigned"}`,
);
});
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ const makeTask = (overrides: Partial<Task> = {}): Task => ({
parent_task_id: null,
description: null,
importance: 0,
assignee_id: null,
assignee_ids: [],
reporter_id: null,
custom_fields: {},
view_position: null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ const makeTask = (overrides: Partial<Task> = {}): Task => ({
parent_task_id: null,
description: null,
importance: 0,
assignee_id: null,
assignee_ids: [],
reporter_id: null,
custom_fields: {},
view_position: null,
Expand Down Expand Up @@ -166,13 +166,13 @@ describe("TaskCard", () => {
it("shows the assignee icon when task has an assignee", () => {
const { container } = render(
<TaskCard
task={makeTask({ assignee_id: "user-1" })}
task={makeTask({ assignee_ids: ["user-1"] })}
statuses={NO_STATUSES}
taskTypes={NO_TYPES}
/>,
);
// assigned state: filled avatar circle with ring-primary/20
const assigneeEl = container.querySelector(".ring-primary\\/20");
// assigned state: filled avatar circle with the primary gradient
const assigneeEl = container.querySelector(".from-primary\\/20");
expect(assigneeEl).toBeInTheDocument();
});
});
126 changes: 70 additions & 56 deletions apps/web/src/components/projects/interactions/task-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,33 +74,54 @@ export function TaskCard({
const { t } = useTranslation("projects");
const [typePopoverOpen, setTypePopoverOpen] = useState(false);
const taskType = taskTypes.find((t) => t.id === task.task_type_id);
const assignee = task.assignee_id
? members.find((m) => m.id === task.assignee_id)
: undefined;
const status = statuses.find((s) => s.id === task.status_id);

/** Renders the chip/indicator for a single field key. */
const renderField = (fieldKey: string) => {
switch (fieldKey) {
case "assignee":
case "assignee": {
const assigneeIds = task.assignee_ids ?? [];
const visible = assigneeIds.slice(0, 3);
const overflow = assigneeIds.length - visible.length;
const avatarStack = (
<div className="flex items-center -space-x-1.5">
{visible.length > 0 ? (
visible.map((id) => {
const m = members.find((mm) => mm.id === id);
return (
<div
key={id}
className="flex size-5 items-center justify-center rounded-full bg-linear-to-br from-primary/20 to-primary/15 text-primary text-xs font-bold ring-2 ring-card"
>
{m ? (
(m.full_name || m.username).slice(0, 1).toUpperCase()
) : (
<User className="size-2.5" />
)}
</div>
);
})
) : (
<div className="flex size-5 items-center justify-center rounded-full bg-linear-to-br from-muted/80 to-muted/40 text-muted-foreground text-xs font-bold ring-1 ring-border/25">
<User className="size-2.5" />
</div>
)}
{overflow > 0 && (
<div className="flex size-5 items-center justify-center rounded-full bg-muted text-muted-foreground text-[10px] font-bold ring-2 ring-card">
+{overflow}
</div>
)}
</div>
);

return canEdit && members.length > 0 ? (
<Popover key="assignee">
<PopoverTrigger
type="button"
onClick={(e) => e.stopPropagation()}
className="nodrag flex size-5 items-center justify-center rounded-full transition-all duration-150 hover:ring-2 hover:ring-primary/30"
className="nodrag flex items-center rounded-full transition-all duration-150 hover:ring-2 hover:ring-primary/30"
>
{assignee ? (
<div className="flex size-5 items-center justify-center rounded-full bg-linear-to-br from-primary/20 to-primary/15 text-primary text-xs font-bold ring-1 ring-primary/20">
{(assignee.full_name || assignee.username)
.slice(0, 1)
.toUpperCase()}
</div>
) : (
<div className="flex size-5 items-center justify-center rounded-full bg-linear-to-br from-muted/80 to-muted/40 text-muted-foreground text-xs font-bold ring-1 ring-border/25">
<User className="size-2.5" />
</div>
)}
{avatarStack}
</PopoverTrigger>
<PopoverContent
className="w-48 p-1 rounded-xl border border-border/40 shadow-lg"
Expand All @@ -111,57 +132,50 @@ export function TaskCard({
className="flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-sm text-muted-foreground hover:bg-muted/60 transition-colors duration-100"
onClick={(e) => {
e.stopPropagation();
onUpdate?.(task.id, { assignee_id: null });
onUpdate?.(task.id, { assignee_ids: [] });
}}
>
<User className="size-3.5 opacity-60" />
<span className="flex-1 text-left">
{t("board.taskCard.unassigned")}
</span>
{!assignee && <Check className="size-3.5 text-primary" />}
{assigneeIds.length === 0 && (
<Check className="size-3.5 text-primary" />
)}
</button>
{members.map((m) => (
<button
key={m.id}
type="button"
className="flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-sm hover:bg-muted/60 transition-colors duration-100"
onClick={(e) => {
e.stopPropagation();
onUpdate?.(task.id, { assignee_id: m.id });
}}
>
<div className="flex size-5 items-center justify-center rounded-full bg-linear-to-br from-primary/20 to-primary/10 text-primary text-xs font-bold">
{(m.full_name || m.username).slice(0, 1).toUpperCase()}
</div>
<span className="flex-1 text-left truncate">
{m.full_name || m.username}
</span>
{m.id === task.assignee_id && (
<Check className="size-3.5 text-primary" />
)}
</button>
))}
{members.map((m) => {
const isSelected = task.assignee_ids?.includes(m.id) ?? false;
return (
<button
key={m.id}
type="button"
className="flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-sm hover:bg-muted/60 transition-colors duration-100"
onClick={(e) => {
e.stopPropagation();
const current = task.assignee_ids ?? [];
onUpdate?.(task.id, {
assignee_ids: isSelected
? current.filter((id) => id !== m.id)
: [...current, m.id],
});
}}
>
<div className="flex size-5 items-center justify-center rounded-full bg-linear-to-br from-primary/20 to-primary/10 text-primary text-xs font-bold">
{(m.full_name || m.username).slice(0, 1).toUpperCase()}
</div>
<span className="flex-1 text-left truncate">
{m.full_name || m.username}
</span>
{isSelected && <Check className="size-3.5 text-primary" />}
</button>
);
})}
</PopoverContent>
</Popover>
) : (
<div
key="assignee"
className={cn(
"flex size-5 items-center justify-center rounded-full text-xs font-bold ring-1",
task.assignee_id
? "bg-linear-to-br from-primary/20 to-primary/15 text-primary ring-primary/20"
: "bg-linear-to-br from-muted/80 to-muted/40 text-muted-foreground ring-border/25",
)}
>
{assignee ? (
(assignee.full_name || assignee.username)
.slice(0, 1)
.toUpperCase()
) : (
<User className="size-2.5" />
)}
</div>
<div key="assignee">{avatarStack}</div>
);
}

case "type":
return canEdit && taskTypes.length > 0 ? (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ export function TaskDetailPanel({
<User className="size-3.5 opacity-70" />
{t("taskDetail.panel.assignee")}
</span>
{task.assignee_id ? (
{task.assignee_ids && task.assignee_ids.length > 0 ? (
<div className="flex items-center gap-2">
<div className="flex size-6 items-center justify-center rounded-full bg-linear-to-br from-primary/20 to-primary/10 text-primary text-xs font-bold ring-1 ring-primary/20">
<User className="size-3" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,14 +66,19 @@ export function describeTaskChange(
return t("taskDetail.activity.changedPriority", { oldVal, newVal });
return t("taskDetail.activity.setPriority", { newVal });
case "assignee": {
const oldName = resolveMember(change.old);
const newName = resolveMember(change.new);
if (hasOld && hasNew)
const oldIds = Array.isArray(change.old) ? change.old : [];
const newIds = Array.isArray(change.new) ? change.new : [];
const resolveMembers = (ids: unknown[]) =>
ids.map((id) => resolveMember(id)).join(", ");
const oldName = resolveMembers(oldIds);
const newName = resolveMembers(newIds);
if (oldIds.length > 0 && newIds.length > 0)
return t("taskDetail.activity.changedAssignee", {
oldName,
newName,
});
if (hasNew) return t("taskDetail.activity.assignedTo", { newName });
if (newIds.length > 0)
return t("taskDetail.activity.assignedTo", { newName });
return t("taskDetail.activity.removedAssignee", { oldName });
}
case "reporter": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -193,8 +193,9 @@ export function TaskActivityPane({
if (typeof oldVal === "number") payload.importance = oldVal;
break;
case "assignee":
payload.assignee_id =
typeof oldVal === "string" && oldVal ? oldVal : null;
payload.assignee_ids = Array.isArray(oldVal)
? oldVal.filter((v): v is string => typeof v === "string")
: [];
break;
case "reporter":
payload.reporter_id =
Expand Down
Loading
Loading