Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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