Kanban card user icon should be in title (vibe-kanban) (#1303)

* Done! The kanban card layout is now restructured with **Title + Avatar + Actions** in the header row, and the description spans the full card width below.

* Done! The avatar is now inline at the start of the title text with:
- Sized down to `h-5 w-5` (from default `h-6 w-6`) to align better with the `text-sm` title
- Inline-block with vertical alignment
- Title text gets full remaining width without squishing
- Actions stay on the right

* Done! Changed to `inline-flex` so the avatar initials are properly centered while staying inline with the title text.

* Done! Created [TaskCardHeader](file:///private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban/worktrees/665f-kanban-card-user/frontend/src/components/tasks/TaskCardHeader.tsx) and updated both cards:

**Changes:**
- **TaskCardHeader**: Shared component with inline avatar + optional actions slot
- **TaskCard**: Now uses TaskCardHeader with status indicators passed via `right` prop
- **SharedTaskCard**: Normalized to use TaskCardHeader with TaskCard styles (no more muted text, consistent spacing/sizing)

Both cards now share the same header pattern with inline avatars.

* invert header avatar colour
This commit is contained in:
Louis Knight-Webb
2025-11-17 15:57:54 +00:00
committed by GitHub
parent d578b6f586
commit 124ebbf32c
4 changed files with 89 additions and 50 deletions

View File

@@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef } from 'react'; import { useCallback, useEffect, useRef } from 'react';
import { KanbanCard } from '@/components/ui/shadcn-io/kanban'; import { KanbanCard } from '@/components/ui/shadcn-io/kanban';
import type { SharedTaskRecord } from '@/hooks/useProjectTasks'; import type { SharedTaskRecord } from '@/hooks/useProjectTasks';
import { UserAvatar } from './UserAvatar'; import { TaskCardHeader } from './TaskCardHeader';
interface SharedTaskCardProps { interface SharedTaskCardProps {
task: SharedTaskRecord; task: SharedTaskRecord;
@@ -48,24 +48,23 @@ export function SharedTaskCard({
dragDisabled dragDisabled
className="relative overflow-hidden pl-5 before:absolute before:left-0 before:top-0 before:bottom-0 before:w-[3px] before:bg-muted-foreground before:content-['']" className="relative overflow-hidden pl-5 before:absolute before:left-0 before:top-0 before:bottom-0 before:w-[3px] before:bg-muted-foreground before:content-['']"
> >
<div className="flex items-center gap-3"> <div className="flex flex-col gap-2">
<UserAvatar <TaskCardHeader
firstName={task.assignee_first_name ?? undefined} title={task.title}
lastName={task.assignee_last_name ?? undefined} avatar={{
username={task.assignee_username ?? undefined} firstName: task.assignee_first_name ?? undefined,
// TODO: Add imageUrl={task.assignee_avatar_url} when backend provides it lastName: task.assignee_last_name ?? undefined,
username: task.assignee_username ?? undefined,
}}
/> />
<div className="flex min-w-0 flex-1 flex-col gap-1 font-light">
<h4 className="text-sm text-muted-foreground line-clamp-2">
{task.title}
</h4>
{task.description && ( {task.description && (
<p className="text-xs text-muted-foreground line-clamp-2"> <p className="text-sm text-secondary-foreground break-words">
{task.description} {task.description.length > 130
? `${task.description.substring(0, 130)}...`
: task.description}
</p> </p>
)} )}
</div> </div>
</div>
</KanbanCard> </KanbanCard>
); );
} }

View File

@@ -8,7 +8,7 @@ import { useNavigateWithSearch } from '@/hooks';
import { paths } from '@/lib/paths'; import { paths } from '@/lib/paths';
import { attemptsApi } from '@/lib/api'; import { attemptsApi } from '@/lib/api';
import type { SharedTaskRecord } from '@/hooks/useProjectTasks'; import type { SharedTaskRecord } from '@/hooks/useProjectTasks';
import { UserAvatar } from './UserAvatar'; import { TaskCardHeader } from './TaskCardHeader';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
type Task = TaskWithAttemptStatus; type Task = TaskWithAttemptStatus;
@@ -93,35 +93,29 @@ export function TaskCard({
: undefined : undefined
} }
> >
<div className="flex gap-3"> <div className="flex flex-col gap-2">
{sharedTask ? ( <TaskCardHeader
<UserAvatar title={task.title}
firstName={sharedTask.assignee_first_name ?? undefined} avatar={
lastName={sharedTask.assignee_last_name ?? undefined} sharedTask
username={sharedTask.assignee_username ?? undefined} ? {
// TODO: Add imageUrl={sharedTask.assignee_avatar_url} when backend provides it firstName: sharedTask.assignee_first_name ?? undefined,
className="self-center" lastName: sharedTask.assignee_last_name ?? undefined,
/> username: sharedTask.assignee_username ?? undefined,
) : null} }
<div className="flex min-w-0 flex-1 flex-col gap-2"> : undefined
<div className="flex flex-1 min-w-0 items-center gap-2"> }
<h4 className="flex-1 min-w-0 line-clamp-2 font-light text-sm"> right={
{task.title} <>
</h4>
<div className="flex items-center gap-1">
{/* In Progress Spinner */}
{task.has_in_progress_attempt && ( {task.has_in_progress_attempt && (
<Loader2 className="h-4 w-4 animate-spin text-blue-500" /> <Loader2 className="h-4 w-4 animate-spin text-blue-500" />
)} )}
{/* Merged Indicator */}
{task.has_merged_attempt && ( {task.has_merged_attempt && (
<CheckCircle className="h-4 w-4 text-green-500" /> <CheckCircle className="h-4 w-4 text-green-500" />
)} )}
{/* Failed Indicator */}
{task.last_attempt_failed && !task.has_merged_attempt && ( {task.last_attempt_failed && !task.has_merged_attempt && (
<XCircle className="h-4 w-4 text-destructive" /> <XCircle className="h-4 w-4 text-destructive" />
)} )}
{/* Parent Task Indicator */}
{task.parent_task_attempt && ( {task.parent_task_attempt && (
<Button <Button
variant="icon" variant="icon"
@@ -134,10 +128,10 @@ export function TaskCard({
<Link className="h-4 w-4" /> <Link className="h-4 w-4" />
</Button> </Button>
)} )}
{/* Actions Menu */}
<ActionsDropdown task={task} sharedTask={sharedTask} /> <ActionsDropdown task={task} sharedTask={sharedTask} />
</div> </>
</div> }
/>
{task.description && ( {task.description && (
<p className="text-sm text-secondary-foreground break-words"> <p className="text-sm text-secondary-foreground break-words">
{task.description.length > 130 {task.description.length > 130
@@ -146,7 +140,6 @@ export function TaskCard({
</p> </p>
)} )}
</div> </div>
</div>
</KanbanCard> </KanbanCard>
); );
} }

View File

@@ -0,0 +1,47 @@
import type { ReactNode } from 'react';
import { UserAvatar } from './UserAvatar';
interface HeaderAvatar {
firstName?: string;
lastName?: string;
username?: string;
imageUrl?: string;
}
interface TaskCardHeaderProps {
title: ReactNode;
avatar?: HeaderAvatar;
right?: ReactNode;
className?: string;
titleClassName?: string;
}
export function TaskCardHeader({
title,
avatar,
right,
className,
titleClassName,
}: TaskCardHeaderProps) {
return (
<div className={`flex items-start gap-3 min-w-0 ${className ?? ''}`}>
<h4
className={`flex-1 min-w-0 line-clamp-2 font-light text-sm ${titleClassName ?? ''}`}
>
{avatar ? (
<UserAvatar
firstName={avatar.firstName}
lastName={avatar.lastName}
username={avatar.username}
imageUrl={avatar.imageUrl}
className="mr-2 inline-flex align-middle h-5 w-5"
/>
) : null}
<span className="align-middle">{title}</span>
</h4>
{right ? (
<div className="flex items-center gap-1 shrink-0">{right}</div>
) : null}
</div>
);
}

View File

@@ -99,7 +99,7 @@ export const UserAvatar = ({
return ( return (
<div <div
className={cn( className={cn(
'flex h-6 w-6 shrink-0 items-center justify-center overflow-hidden rounded-full border border-border bg-muted text-xs font-medium text-muted-foreground', 'flex h-6 w-6 shrink-0 items-center justify-center overflow-hidden rounded-full border border-border bg-muted-foreground text-xs font-medium text-muted',
className className
)} )}
title={label} title={label}