feat: improve cards

This commit is contained in:
Carlos Florêncio
2026-01-14 02:26:30 +00:00
parent 90e02075e8
commit 42d4be97e5
8 changed files with 212 additions and 143 deletions
+1 -1
View File
@@ -38,7 +38,7 @@ function DialogOverlay({
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn("data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/80 duration-100 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 isolate z-50", className)}
className={cn("data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 duration-100 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 isolate z-50", className)}
{...props}
/>
)
+1 -1
View File
@@ -62,7 +62,7 @@ export function Combobox({
variant="outline"
role="combobox"
aria-expanded={open}
className={cn("w-full justify-between", triggerClassName)}
className={cn("w-full justify-between", !disabled && "cursor-pointer", triggerClassName)}
disabled={disabled}
>
<div className="flex min-w-0 flex-1 items-center">
+1 -1
View File
@@ -64,7 +64,7 @@ export function KanbanBoardGrid({
</div>
) : (
<div
className="grid gap-[3px] rounded-lg overflow-hidden h-full p-[2px]"
className="grid gap-2 rounded-lg overflow-hidden h-full p-[2px]"
style={{ gridTemplateColumns: `repeat(${columns.length}, minmax(0, 1fr))` }}
>
{columns.map((column) => (
+73 -53
View File
@@ -10,7 +10,7 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from '@kandev/ui/dropdown-menu';
import { cn, extractRepoName } from '@/lib/utils';
import { cn, getRepositoryDisplayName } from '@/lib/utils';
export interface Task {
id: string;
@@ -29,19 +29,42 @@ interface KanbanCardProps {
onDelete?: (task: Task) => void;
}
function KanbanCardBody({
task,
repoName,
actions,
}: {
task: Task;
repoName: string | null;
actions?: React.ReactNode;
}) {
return (
<>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
{repoName && (
<p className="text-[10px] mb-1 text-muted-foreground leading-tight truncate">{repoName}</p>
)}
<p className="text-sm font-medium leading-tight line-clamp-1">{task.title}</p>
</div>
{actions}
</div>
{task.description && (
<p className="text-xs text-muted-foreground mt-1 leading-tight line-clamp-3">
{task.description}
</p>
)}
</>
);
}
function KanbanCardLayout({ task, className }: KanbanCardProps & { className?: string }) {
const repoName = extractRepoName(task.repositoryUrl);
const repoName = getRepositoryDisplayName(task.repositoryUrl);
return (
<Card size="sm" className={cn('w-full py-0', className)}>
<CardContent className="px-2 py-1">
{repoName && (
<p className="text-xs text-muted-foreground leading-tight mb-1 truncate">{repoName}</p>
)}
<p className="text-sm font-medium leading-tight">{task.title}</p>
{task.description && (
<p className="text-xs text-muted-foreground mt-1 leading-tight">{task.description}</p>
)}
<KanbanCardBody task={task} repoName={repoName} />
</CardContent>
</Card>
);
@@ -52,7 +75,7 @@ export function KanbanCard({ task, onClick, onEdit, onDelete }: KanbanCardProps)
id: task.id,
});
const repoName = extractRepoName(task.repositoryUrl);
const repoName = getRepositoryDisplayName(task.repositoryUrl);
const statusIcon = (() => {
switch (task.state) {
@@ -87,7 +110,7 @@ export function KanbanCard({ task, onClick, onEdit, onDelete }: KanbanCardProps)
ref={setNodeRef}
style={style}
className={cn(
'cursor-pointer mb-2 w-full py-0 relative border border-border overflow-visible shadow-none ring-0',
'max-h-48 rounded-sm data-[size=sm]:py-1 cursor-pointer mb-2 w-full py-0 relative border border-border overflow-visible shadow-none ring-0',
task.state === 'IN_PROGRESS' && 'kanban-task-pulse',
isDragging && 'opacity-50 z-50'
)}
@@ -96,48 +119,45 @@ export function KanbanCard({ task, onClick, onEdit, onDelete }: KanbanCardProps)
{...attributes}
>
<CardContent className="px-2 py-1">
{repoName && (
<p className="text-xs text-muted-foreground leading-tight mb-1 truncate">{repoName}</p>
)}
<div className="flex items-start justify-between gap-2">
<p className="text-sm font-medium leading-tight">{task.title}</p>
<div className="flex items-center gap-2">
{statusIcon}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="text-muted-foreground hover:text-foreground cursor-pointer"
onClick={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
>
<IconDots className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={(event) => {
event.stopPropagation();
onEdit?.(task);
}}
>
Edit
</DropdownMenuItem>
<DropdownMenuItem
onClick={(event) => {
event.stopPropagation();
onDelete?.(task);
}}
>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
{task.description && (
<p className="text-xs text-muted-foreground mt-1 leading-tight">{task.description}</p>
)}
<KanbanCardBody
task={task}
repoName={repoName}
actions={
<div className="flex items-center gap-2">
{statusIcon}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="text-muted-foreground hover:text-foreground cursor-pointer"
onClick={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
>
<IconDots className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={(event) => {
event.stopPropagation();
onEdit?.(task);
}}
>
Edit
</DropdownMenuItem>
<DropdownMenuItem
onClick={(event) => {
event.stopPropagation();
onDelete?.(task);
}}
>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
}
/>
</CardContent>
</Card>
);
+1 -1
View File
@@ -28,7 +28,7 @@ export function KanbanColumn({ column, tasks, onOpenTask, onEditTask, onDeleteTa
<div
ref={setNodeRef}
className={cn(
'flex flex-col h-full min-w-0 bg-card p-3 min-h-[600px] rounded-lg',
'flex flex-col h-full min-w-0 p-4 min-h-[600px] rounded-sm border border-border',
isOver && 'ring-2 ring-primary'
)}
>
+13 -10
View File
@@ -262,7 +262,7 @@ export function TaskCreateDialog({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-none w-[900px] sm:!max-w-none max-h-[85vh] flex flex-col">
<DialogContent className="max-w-none w-[900px] sm:!max-w-none max-h-[85vh] flex flex-col bg-card">
<DialogHeader>
<DialogTitle>{submitLabel === 'Create' ? 'Create Task' : 'Edit Task'}</DialogTitle>
</DialogHeader>
@@ -292,9 +292,9 @@ export function TaskCreateDialog({
{repositories.length === 0 && !repositoriesLoading ? (
<div className="flex items-center justify-center h-10 px-3 py-2 text-sm border border-input rounded-md bg-background">
<span className="text-muted-foreground mr-2">No repositories found.</span>
<Link href="/settings/workspace" className="text-primary hover:underline">
Add repository
</Link>
<Link href="/settings/workspace" className="text-primary hover:underline">
Add repository
</Link>
</div>
) : (
<Combobox
@@ -324,12 +324,13 @@ export function TaskCreateDialog({
emptyMessage="No repository found."
disabled={isEditMode || repositoriesLoading}
dropdownLabel="Repository"
className={isEditMode || repositoriesLoading ? undefined : 'cursor-pointer'}
/>
)}
</div>
<div>
<Combobox
options={branches.map((branchObj) => {
<Combobox
options={branches.map((branchObj) => {
const displayName = branchObj.type === 'remote' && branchObj.remote
? `${branchObj.remote}/${branchObj.name}`
: branchObj.name;
@@ -362,12 +363,13 @@ export function TaskCreateDialog({
searchPlaceholder="Search branches..."
emptyMessage="No branch found."
disabled={isEditMode || !repositoryId || branchesLoading}
dropdownLabel="Branch"
dropdownLabel="Base Branch"
className={isEditMode || !repositoryId || branchesLoading ? undefined : 'cursor-pointer'}
/>
</div>
<div>
{agentProfiles.length === 0 && !agentProfilesLoading ? (
<div className="flex h-7 items-center justify-center gap-2 rounded-md border border-input bg-background px-3 text-sm text-muted-foreground">
<div className="flex h-7 items-center justify-center gap-2 rounded-sm border border-input px-3 text-xs text-muted-foreground">
<span>No agents found.</span>
<Link href="/settings/agents" className="text-primary hover:underline">
Add agent
@@ -391,6 +393,7 @@ export function TaskCreateDialog({
searchPlaceholder="Search agents..."
emptyMessage="No agent found."
dropdownLabel="Agent profile"
className={agentProfilesLoading ? undefined : 'cursor-pointer'}
/>
)}
</div>
@@ -401,7 +404,7 @@ export function TaskCreateDialog({
variant="ghost"
size="sm"
onClick={() => setShowAdvancedSettings(!showAdvancedSettings)}
className="text-muted-foreground"
className="text-muted-foreground cursor-pointer"
>
<IconSettings className="h-4 w-4 mr-1" />
{showAdvancedSettings ? 'Hide' : 'More Options'}
@@ -486,7 +489,7 @@ export function TaskCreateDialog({
</div>
{startAgent && (
<span className="text-xs text-muted-foreground">
A worktree will be created from this base branch.
A git worktree will be created from the above branch.
</span>
)}
</div>
+31 -1
View File
@@ -1,5 +1,11 @@
import { describe, expect, it } from 'vitest';
import { formatUserHomePath, selectPreferredBranch, truncateRepoPath } from './utils';
import {
extractRepoName,
formatUserHomePath,
getRepositoryDisplayName,
selectPreferredBranch,
truncateRepoPath,
} from './utils';
describe('formatUserHomePath', () => {
it('replaces mac home path with tilde', () => {
@@ -62,3 +68,27 @@ describe('selectPreferredBranch', () => {
expect(selectPreferredBranch(branches)).toBeNull();
});
});
describe('extractRepoName', () => {
it('extracts org/name from ssh urls', () => {
expect(extractRepoName('git@gitlab.com:org/repo.git')).toBe('org/repo');
});
it('extracts org/name from https urls', () => {
expect(extractRepoName('https://bitbucket.org/org/repo')).toBe('org/repo');
});
it('returns null for local paths', () => {
expect(extractRepoName('/Users/alex/Projects/App')).toBeNull();
});
});
describe('getRepositoryDisplayName', () => {
it('returns a tilde path for local repositories', () => {
expect(getRepositoryDisplayName('/Users/alex/Projects/App')).toBe('~/Projects/App');
});
it('returns org/name for remote repositories', () => {
expect(getRepositoryDisplayName('https://github.com/org/repo.git')).toBe('org/repo');
});
});
+91 -75
View File
@@ -2,7 +2,7 @@ import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
return twMerge(clsx(inputs))
}
/**
@@ -10,43 +10,59 @@ export function cn(...inputs: ClassValue[]) {
* (e.g., HTTP on non-localhost where crypto.randomUUID is unavailable).
*/
export function generateUUID(): string {
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
return crypto.randomUUID();
}
// Fallback implementation
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
const v = c === 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
return crypto.randomUUID();
}
// Fallback implementation
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
const v = c === 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
/**
* Extract organization/repository name from a repository URL or path.
* Supports GitHub URLs and local paths.
* Extract organization/repository name from a repository URL.
* Supports common Git providers and SSH/HTTPS formats.
* @param repositoryUrl - The repository URL or local path
* @returns The org/repo-name format, or null if unable to extract
*/
export function extractRepoName(repositoryUrl: string | null | undefined): string | null {
if (!repositoryUrl) return null;
if (!repositoryUrl) return null;
try {
// Try to parse as GitHub URL (https://github.com/org/repo or git@github.com:org/repo)
const githubHttpMatch = repositoryUrl.match(/github\.com[:/]([^/]+\/[^/]+?)(\.git)?$/);
if (githubHttpMatch) {
return githubHttpMatch[1];
try {
const normalized = repositoryUrl.replace(/\.git$/, '');
// SSH format: git@host:org/repo
const sshMatch = normalized.match(/^[^@]+@[^:]+:([^/]+\/[^/]+)$/);
if (sshMatch) {
return sshMatch[1];
}
// HTTPS format: https://host/org/repo or http(s)://host/org/repo
const httpMatch = normalized.match(/^https?:\/\/[^/]+\/([^/]+\/[^/]+)$/);
if (httpMatch) {
return httpMatch[1];
}
return null;
} catch {
return null;
}
}
// Try to extract from local path (e.g., /path/to/org/repo or ~/org/repo)
const pathMatch = repositoryUrl.match(/([^/]+\/[^/]+)\/?$/);
if (pathMatch) {
return pathMatch[1];
export function isLocalRepositoryPath(repositoryUrl: string): boolean {
return (
repositoryUrl.startsWith('/') ||
repositoryUrl.startsWith('~/') ||
/^[A-Za-z]:[\\/]/.test(repositoryUrl)
);
}
export function getRepositoryDisplayName(repositoryUrl: string | null | undefined): string | null {
if (!repositoryUrl) return null;
if (isLocalRepositoryPath(repositoryUrl)) {
return formatUserHomePath(repositoryUrl.replace(/\.git$/, ''));
}
return null;
} catch {
return null;
}
return extractRepoName(repositoryUrl) ?? repositoryUrl;
}
/**
@@ -54,71 +70,71 @@ export function extractRepoName(repositoryUrl: string | null | undefined): strin
* Supports macOS/Linux (/Users/name, /home/name) and Windows (C:\Users\name).
*/
export function formatUserHomePath(path: string): string {
if (!path) return path;
const normalized = path.replace(/\\/g, '/');
const macMatch = normalized.match(/^\/Users\/[^/]+(\/.*)?$/);
if (macMatch) {
return `~${macMatch[1] ?? ''}`;
}
const linuxMatch = normalized.match(/^\/home\/[^/]+(\/.*)?$/);
if (linuxMatch) {
return `~${linuxMatch[1] ?? ''}`;
}
const windowsMatch = normalized.match(/^[A-Za-z]:\/Users\/[^/]+(\/.*)?$/);
if (windowsMatch) {
return `~${windowsMatch[1] ?? ''}`;
}
return path;
if (!path) return path;
const normalized = path.replace(/\\/g, '/');
const macMatch = normalized.match(/^\/Users\/[^/]+(\/.*)?$/);
if (macMatch) {
return `~${macMatch[1] ?? ''}`;
}
const linuxMatch = normalized.match(/^\/home\/[^/]+(\/.*)?$/);
if (linuxMatch) {
return `~${linuxMatch[1] ?? ''}`;
}
const windowsMatch = normalized.match(/^[A-Za-z]:\/Users\/[^/]+(\/.*)?$/);
if (windowsMatch) {
return `~${windowsMatch[1] ?? ''}`;
}
return path;
}
/**
* Truncate a repository path for display, favoring the last segments.
*/
export function truncateRepoPath(path: string, maxLength = 28): string {
const displayPath = formatUserHomePath(path);
if (displayPath.length <= maxLength) return displayPath;
const normalizedPath = displayPath.replace(/\\/g, '/');
const hasHomePrefix = normalizedPath.startsWith('~/');
const hasRootPrefix = normalizedPath.startsWith('/');
const prefix = hasHomePrefix ? '~/' : hasRootPrefix ? '/' : '';
const parts = normalizedPath.replace(/^~\//, '').replace(/^\//, '').split('/').filter(Boolean);
if (parts.length === 0) return displayPath;
const lastTwo = parts.slice(-2).join('/');
let result = `${prefix}.../${lastTwo}`;
if (result.length <= maxLength) return result;
const lastOne = parts.slice(-1).join('/');
result = `${prefix}.../${lastOne}`;
if (result.length <= maxLength) return result;
const remaining = Math.max(1, maxLength - prefix.length - 4);
return `${prefix}.../${lastOne.slice(-remaining)}`;
const displayPath = formatUserHomePath(path);
if (displayPath.length <= maxLength) return displayPath;
const normalizedPath = displayPath.replace(/\\/g, '/');
const hasHomePrefix = normalizedPath.startsWith('~/');
const hasRootPrefix = normalizedPath.startsWith('/');
const prefix = hasHomePrefix ? '~/' : hasRootPrefix ? '/' : '';
const parts = normalizedPath.replace(/^~\//, '').replace(/^\//, '').split('/').filter(Boolean);
if (parts.length === 0) return displayPath;
const lastTwo = parts.slice(-2).join('/');
let result = `${prefix}.../${lastTwo}`;
if (result.length <= maxLength) return result;
const lastOne = parts.slice(-1).join('/');
result = `${prefix}.../${lastOne}`;
if (result.length <= maxLength) return result;
const remaining = Math.max(1, maxLength - prefix.length - 4);
return `${prefix}.../${lastOne.slice(-remaining)}`;
}
type BranchSelectionCandidate = {
name: string;
type?: string;
remote?: string;
name: string;
type?: string;
remote?: string;
};
export function selectPreferredBranch(
branches: BranchSelectionCandidate[]
branches: BranchSelectionCandidate[]
): string | null {
const originMain = branches.find(
(branch) => branch.type === 'remote' && branch.remote === 'origin' && branch.name === 'main'
);
if (originMain) return 'origin/main';
const originMain = branches.find(
(branch) => branch.type === 'remote' && branch.remote === 'origin' && branch.name === 'main'
);
if (originMain) return 'origin/main';
const originMaster = branches.find(
(branch) => branch.type === 'remote' && branch.remote === 'origin' && branch.name === 'master'
);
if (originMaster) return 'origin/master';
const originMaster = branches.find(
(branch) => branch.type === 'remote' && branch.remote === 'origin' && branch.name === 'master'
);
if (originMaster) return 'origin/master';
const localMain = branches.find((branch) => branch.type === 'local' && branch.name === 'main');
if (localMain) return 'main';
const localMain = branches.find((branch) => branch.type === 'local' && branch.name === 'main');
if (localMain) return 'main';
const localMaster = branches.find((branch) => branch.type === 'local' && branch.name === 'master');
if (localMaster) return 'master';
const localMaster = branches.find((branch) => branch.type === 'local' && branch.name === 'master');
if (localMaster) return 'master';
return null;
return null;
}
export const DEFAULT_LOCAL_ENVIRONMENT_KIND = 'local_pc';