From 42d4be97e5b1bbfa92b05f0ce1f6e17cdd5492a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Wed, 14 Jan 2026 02:26:30 +0000 Subject: [PATCH] feat: improve cards --- apps/packages/ui/src/dialog.tsx | 2 +- apps/web/components/combobox.tsx | 2 +- apps/web/components/kanban-board-grid.tsx | 2 +- apps/web/components/kanban-card.tsx | 126 +++++++++------- apps/web/components/kanban-column.tsx | 2 +- apps/web/components/task-create-dialog.tsx | 23 +-- apps/web/lib/utils.test.ts | 32 +++- apps/web/lib/utils.ts | 166 +++++++++++---------- 8 files changed, 212 insertions(+), 143 deletions(-) diff --git a/apps/packages/ui/src/dialog.tsx b/apps/packages/ui/src/dialog.tsx index eef723c57a..e62692d5eb 100644 --- a/apps/packages/ui/src/dialog.tsx +++ b/apps/packages/ui/src/dialog.tsx @@ -38,7 +38,7 @@ function DialogOverlay({ return ( ) diff --git a/apps/web/components/combobox.tsx b/apps/web/components/combobox.tsx index 063c3c9bd9..997e5b0336 100644 --- a/apps/web/components/combobox.tsx +++ b/apps/web/components/combobox.tsx @@ -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} >
diff --git a/apps/web/components/kanban-board-grid.tsx b/apps/web/components/kanban-board-grid.tsx index 7883236b42..d42fe70b1e 100644 --- a/apps/web/components/kanban-board-grid.tsx +++ b/apps/web/components/kanban-board-grid.tsx @@ -64,7 +64,7 @@ export function KanbanBoardGrid({
) : (
{columns.map((column) => ( diff --git a/apps/web/components/kanban-card.tsx b/apps/web/components/kanban-card.tsx index 4b85a3e96c..292be83280 100644 --- a/apps/web/components/kanban-card.tsx +++ b/apps/web/components/kanban-card.tsx @@ -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 ( + <> +
+
+ {repoName && ( +

{repoName}

+ )} +

{task.title}

+
+ {actions} +
+ {task.description && ( +

+ {task.description} +

+ )} + + ); +} + function KanbanCardLayout({ task, className }: KanbanCardProps & { className?: string }) { - const repoName = extractRepoName(task.repositoryUrl); + const repoName = getRepositoryDisplayName(task.repositoryUrl); return ( - {repoName && ( -

{repoName}

- )} -

{task.title}

- {task.description && ( -

{task.description}

- )} +
); @@ -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} > - {repoName && ( -

{repoName}

- )} -
-

{task.title}

-
- {statusIcon} - - - - - - { - event.stopPropagation(); - onEdit?.(task); - }} - > - Edit - - { - event.stopPropagation(); - onDelete?.(task); - }} - > - Delete - - - -
-
- {task.description && ( -

{task.description}

- )} + + {statusIcon} + + + + + + { + event.stopPropagation(); + onEdit?.(task); + }} + > + Edit + + { + event.stopPropagation(); + onDelete?.(task); + }} + > + Delete + + + +
+ } + /> ); diff --git a/apps/web/components/kanban-column.tsx b/apps/web/components/kanban-column.tsx index 6d57298464..0955bb8b06 100644 --- a/apps/web/components/kanban-column.tsx +++ b/apps/web/components/kanban-column.tsx @@ -28,7 +28,7 @@ export function KanbanColumn({ column, tasks, onOpenTask, onEditTask, onDeleteTa
diff --git a/apps/web/components/task-create-dialog.tsx b/apps/web/components/task-create-dialog.tsx index 9e40bfe43f..e069206581 100644 --- a/apps/web/components/task-create-dialog.tsx +++ b/apps/web/components/task-create-dialog.tsx @@ -262,7 +262,7 @@ export function TaskCreateDialog({ return ( - + {submitLabel === 'Create' ? 'Create Task' : 'Edit Task'} @@ -292,9 +292,9 @@ export function TaskCreateDialog({ {repositories.length === 0 && !repositoriesLoading ? (
No repositories found. - - Add repository - + + Add repository +
) : ( )}
- { + { 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'} />
{agentProfiles.length === 0 && !agentProfilesLoading ? ( -
+
No agents found. Add agent @@ -391,6 +393,7 @@ export function TaskCreateDialog({ searchPlaceholder="Search agents..." emptyMessage="No agent found." dropdownLabel="Agent profile" + className={agentProfilesLoading ? undefined : 'cursor-pointer'} /> )}
@@ -401,7 +404,7 @@ export function TaskCreateDialog({ variant="ghost" size="sm" onClick={() => setShowAdvancedSettings(!showAdvancedSettings)} - className="text-muted-foreground" + className="text-muted-foreground cursor-pointer" > {showAdvancedSettings ? 'Hide' : 'More Options'} @@ -486,7 +489,7 @@ export function TaskCreateDialog({
{startAgent && ( - A worktree will be created from this base branch. + A git worktree will be created from the above branch. )}
diff --git a/apps/web/lib/utils.test.ts b/apps/web/lib/utils.test.ts index b62554cfe9..fa481834d5 100644 --- a/apps/web/lib/utils.test.ts +++ b/apps/web/lib/utils.test.ts @@ -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'); + }); +}); diff --git a/apps/web/lib/utils.ts b/apps/web/lib/utils.ts index cf4046ccbb..2cfbc6c191 100644 --- a/apps/web/lib/utils.ts +++ b/apps/web/lib/utils.ts @@ -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';