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 (
- {
+ {
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';