diff --git a/panel/src/app/(dashboard)/tasks/page.tsx b/panel/src/app/(dashboard)/tasks/page.tsx index c6b07f6a..a3185a4b 100644 --- a/panel/src/app/(dashboard)/tasks/page.tsx +++ b/panel/src/app/(dashboard)/tasks/page.tsx @@ -7,7 +7,13 @@ import { useProjects } from "@/hooks/use-projects"; import { useProducts } from "@/hooks/use-products"; import { TaskStatus, Team, TaskType } from "@/types"; import { OfflineState } from "@/components/ui/offline-state"; -import { CreateTaskDialog, TaskFilters, TaskTable, SortField, SortDirection } from "@/components/tasks"; +import { + CreateTaskDialog, + TaskFilters, + TaskTable, + SortField, + SortDirection, +} from "@/components/tasks"; import { Button } from "@/components/ui/button"; import { Skeleton } from "@/components/ui/skeleton"; import { RefreshCw } from "lucide-react"; @@ -21,27 +27,27 @@ function TasksPageContent() { const statusParam = searchParams.get("status"); const statusFilter = useMemo( () => (statusParam?.split(",").filter(Boolean) as TaskStatus[]) || [], - [statusParam] + [statusParam], ); const teamParam = searchParams.get("team"); const teamFilter = useMemo( () => (teamParam?.split(",").filter(Boolean) as Team[]) || [], - [teamParam] + [teamParam], ); const taskTypeParam = searchParams.get("type"); const taskTypeFilter = useMemo( () => (taskTypeParam?.split(",").filter(Boolean) as TaskType[]) || [], - [taskTypeParam] + [taskTypeParam], ); const projectParam = searchParams.get("project"); const projectFilter = useMemo( () => projectParam?.split(",").filter(Boolean) || [], - [projectParam] + [projectParam], ); const productParam = searchParams.get("product"); const productFilter = useMemo( () => productParam?.split(",").filter(Boolean) || [], - [productParam] + [productParam], ); // Table state from URL @@ -52,71 +58,106 @@ function TasksPageContent() { const expandedParam = searchParams.get("expanded"); const expandedIds = useMemo( () => new Set(expandedParam?.split(",").filter(Boolean) || []), - [expandedParam] + [expandedParam], ); // Update URL params - const updateParams = useCallback((updates: Record) => { - const params = new URLSearchParams(searchParams.toString()); - Object.entries(updates).forEach(([key, value]) => { - if (value) { - params.set(key, value); - } else { - params.delete(key); - } - }); - const query = params.toString(); - router.push(query ? `/tasks?${query}` : "/tasks"); - }, [router, searchParams]); + const updateParams = useCallback( + (updates: Record) => { + const params = new URLSearchParams(searchParams.toString()); + Object.entries(updates).forEach(([key, value]) => { + if (value) { + params.set(key, value); + } else { + params.delete(key); + } + }); + const query = params.toString(); + router.push(query ? `/tasks?${query}` : "/tasks"); + }, + [router, searchParams], + ); - const handleSearchChange = useCallback((value: string) => { - updateParams({ q: value || null }); - }, [updateParams]); + const handleSearchChange = useCallback( + (value: string) => { + updateParams({ q: value || null }); + }, + [updateParams], + ); - const handleStatusChange = useCallback((value: TaskStatus[]) => { - updateParams({ status: value.length > 0 ? value.join(",") : null }); - }, [updateParams]); + const handleStatusChange = useCallback( + (value: TaskStatus[]) => { + updateParams({ status: value.length > 0 ? value.join(",") : null }); + }, + [updateParams], + ); - const handleTeamChange = useCallback((value: Team[]) => { - updateParams({ team: value.length > 0 ? value.join(",") : null }); - }, [updateParams]); + const handleTeamChange = useCallback( + (value: Team[]) => { + updateParams({ team: value.length > 0 ? value.join(",") : null }); + }, + [updateParams], + ); - const handleTaskTypeChange = useCallback((value: TaskType[]) => { - updateParams({ type: value.length > 0 ? value.join(",") : null }); - }, [updateParams]); + const handleTaskTypeChange = useCallback( + (value: TaskType[]) => { + updateParams({ type: value.length > 0 ? value.join(",") : null }); + }, + [updateParams], + ); - const handleProjectChange = useCallback((value: string[]) => { - updateParams({ project: value.length > 0 ? value.join(",") : null }); - }, [updateParams]); + const handleProjectChange = useCallback( + (value: string[]) => { + updateParams({ project: value.length > 0 ? value.join(",") : null }); + }, + [updateParams], + ); - const handleProductChange = useCallback((value: string[]) => { - updateParams({ product: value.length > 0 ? value.join(",") : null }); - }, [updateParams]); + const handleProductChange = useCallback( + (value: string[]) => { + updateParams({ product: value.length > 0 ? value.join(",") : null }); + }, + [updateParams], + ); // Table state handlers - const handleSortChange = useCallback((field: SortField, direction: SortDirection | null) => { - if (direction === null) { - updateParams({ sortBy: null, sortDir: null, page: null }); - } else { + const handleSortChange = useCallback( + (field: SortField, direction: SortDirection | null) => { + if (direction === null) { + updateParams({ sortBy: null, sortDir: null, page: null }); + } else { + updateParams({ + sortBy: field === "created_at" ? null : field, + sortDir: direction === "desc" ? null : direction, + page: null, + }); + } + }, + [updateParams], + ); + + const handlePageChange = useCallback( + (page: number) => { + updateParams({ page: page === 1 ? null : String(page) }); + }, + [updateParams], + ); + + const handlePageSizeChange = useCallback( + (size: number) => { + updateParams({ size: size === 25 ? null : String(size), page: null }); + }, + [updateParams], + ); + + const handleExpandedChange = useCallback( + (ids: Set) => { updateParams({ - sortBy: field === "created_at" ? null : field, - sortDir: direction === "desc" ? null : direction, - page: null, + expanded: ids.size > 0 ? Array.from(ids).join(",") : null, }); - } - }, [updateParams]); - - const handlePageChange = useCallback((page: number) => { - updateParams({ page: page === 1 ? null : String(page) }); - }, [updateParams]); - - const handlePageSizeChange = useCallback((size: number) => { - updateParams({ size: size === 25 ? null : String(size), page: null }); - }, [updateParams]); - - const handleExpandedChange = useCallback((ids: Set) => { - updateParams({ expanded: ids.size > 0 ? Array.from(ids).join(",") : null }); - }, [updateParams]); + }, + [updateParams], + ); // Fetch all tasks and filter client-side for multi-select const { data: tasks, isLoading, error, refetch } = useTasks(); @@ -126,19 +167,23 @@ function TasksPageContent() { const { data: products } = useProducts(); const projectNames = useMemo( () => Object.fromEntries((projects ?? []).map((p) => [p.id, p.name])), - [projects] + [projects], + ); + const projectGitUrls = useMemo( + () => Object.fromEntries((projects ?? []).map((p) => [p.id, p.git_url])), + [projects], ); const productNames = useMemo( () => Object.fromEntries((products ?? []).map((p) => [p.id, p.name])), - [products] + [products], ); const projectOptions = useMemo( () => (projects ?? []).map((p) => ({ value: p.id, label: p.name })), - [projects] + [projects], ); const productOptions = useMemo( () => (products ?? []).map((p) => ({ value: p.id, label: p.name })), - [products] + [products], ); // Filter tasks based on multi-select filters @@ -147,7 +192,10 @@ function TasksPageContent() { return tasks.filter((task) => { // Search filter - if (searchQuery && !task.title.toLowerCase().includes(searchQuery.toLowerCase())) { + if ( + searchQuery && + !task.title.toLowerCase().includes(searchQuery.toLowerCase()) + ) { return false; } @@ -163,30 +211,48 @@ function TasksPageContent() { // Task type filter (if any selected, task must match one of them) // Note: task_type may be undefined until backend adds it to TaskResponse - if (taskTypeFilter.length > 0 && task.task_type && !taskTypeFilter.includes(task.task_type)) { + if ( + taskTypeFilter.length > 0 && + task.task_type && + !taskTypeFilter.includes(task.task_type) + ) { return false; } // Project filter (a task with no project_id is excluded when filtering by project) - if (projectFilter.length > 0 && (!task.project_id || !projectFilter.includes(task.project_id))) { + if ( + projectFilter.length > 0 && + (!task.project_id || !projectFilter.includes(task.project_id)) + ) { return false; } // Product filter (a task with no product_id is excluded when filtering by product) - if (productFilter.length > 0 && (!task.product_id || !productFilter.includes(task.product_id))) { + if ( + productFilter.length > 0 && + (!task.product_id || !productFilter.includes(task.product_id)) + ) { return false; } return true; }); - }, [tasks, searchQuery, statusFilter, teamFilter, taskTypeFilter, projectFilter, productFilter]); + }, [ + tasks, + searchQuery, + statusFilter, + teamFilter, + taskTypeFilter, + projectFilter, + productFilter, + ]); // Check if it's a connection error (backend not running) - const isOffline = error && ( - error.message?.includes("Network Error") || - error.message?.includes("ECONNREFUSED") || - (error as { code?: string })?.code === "ERR_NETWORK" - ); + const isOffline = + error && + (error.message?.includes("Network Error") || + error.message?.includes("ECONNREFUSED") || + (error as { code?: string })?.code === "ERR_NETWORK"); return (
@@ -239,6 +305,7 @@ function TasksPageContent() { tasks={filteredTasks} isLoading={isLoading} projectNames={projectNames} + projectGitUrls={projectGitUrls} productNames={productNames} sortField={sortField} sortDirection={sortDir} @@ -258,18 +325,20 @@ function TasksPageContent() { // Wrap in Suspense for useSearchParams export default function TasksPage() { return ( - -
-
- - + +
+
+ + +
+ +
- - -
- }> + } + >
); diff --git a/panel/src/components/tasks/git-status-badge.tsx b/panel/src/components/tasks/git-status-badge.tsx index aabe9b2b..20b46280 100644 --- a/panel/src/components/tasks/git-status-badge.tsx +++ b/panel/src/components/tasks/git-status-badge.tsx @@ -1,24 +1,61 @@ "use client"; +import type { ReactNode } from "react"; import { GitBranch, GitPullRequest, FileCheck } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { Task, TaskStatus } from "@/types"; +import { branchUrl, pullUrl } from "@/lib/repo-url"; interface GitStatusBadgeProps { task: Task; compact?: boolean; + /** Project git_url — used to build the clickable branch / PR links. */ + repoUrl?: string | null; } -export function GitStatusBadge({ task, compact = true }: GitStatusBadgeProps) { +/** + * Wrap a badge in an external link when a URL is available, otherwise render it + * plain. The badge keeps its exact look; the link only adds the click target + * (and a subtle hover). The parent row's click handler ignores `` clicks, so + * opening a branch/PR never also toggles the row. + */ +function MaybeLink({ + href, + children, +}: { + href: string | null; + children: ReactNode; +}) { + if (!href) return <>{children}; + return ( + + {children} + + ); +} + +export function GitStatusBadge({ + task, + compact = true, + repoUrl, +}: GitStatusBadgeProps) { // All tasks follow git workflow - show relevant status // Show PR badge with status (highest priority) if (task.pr_number) { return ( - - - PR #{task.pr_number} - + + + + PR #{task.pr_number} + + ); } @@ -55,10 +92,12 @@ export function GitStatusBadge({ task, compact = true }: GitStatusBadgeProps) { // Show branch badge (when branch exists but no PR yet) if (task.branch_name) { return ( - - - {compact ? "Branch" : task.branch_name} - + + + + {compact ? "Branch" : task.branch_name} + + ); } diff --git a/panel/src/components/tasks/task-detail/task-metadata.tsx b/panel/src/components/tasks/task-detail/task-metadata.tsx index 205c781b..e192e746 100644 --- a/panel/src/components/tasks/task-detail/task-metadata.tsx +++ b/panel/src/components/tasks/task-detail/task-metadata.tsx @@ -14,9 +14,24 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { User, Calendar, Clock, Target, AlertTriangle, GitBranch, FolderGit2, Wrench, Briefcase, Hash, GitPullRequest, ExternalLink } from "lucide-react"; +import { + User, + Calendar, + Clock, + Target, + AlertTriangle, + GitBranch, + FolderGit2, + Wrench, + Briefcase, + Hash, + GitPullRequest, + ExternalLink, +} from "lucide-react"; import { toast } from "sonner"; import { getAgentDisplayName, resolveToSlug } from "@/lib/agent-utils"; +import { branchUrl } from "@/lib/repo-url"; +import { CopyButton } from "@/components/ui/copy-button"; import { TaskTypeBadge } from "../task-type-badge"; import { DocsStatusBadge } from "../docs-status-badge"; import Link from "next/link"; @@ -80,6 +95,7 @@ function formatDateForInput(date: string | null): string { export function TaskMetadata({ task }: TaskMetadataProps) { const updateTask = useUpdateTask(); const { data: project } = useProject(task.project_id ?? ""); + const branchHref = branchUrl(project?.git_url, task.branch_name); // Editing states - use local state only while editing const [editingAssigned, setEditingAssigned] = useState(false); @@ -91,10 +107,14 @@ export function TaskMetadata({ task }: TaskMetadataProps) { const targetDateInputRef = useRef(null); // Display prop value when not editing, local value when editing - const assignedValue = editingAssigned ? localAssignedValue : (task.assigned_to ?? ""); + const assignedValue = editingAssigned + ? localAssignedValue + : (task.assigned_to ?? ""); const setAssignedValue = (value: string) => setLocalAssignedValue(value); - const targetDateValue = editingTargetDate ? localTargetDateValue : formatDateForInput(task.target_date); + const targetDateValue = editingTargetDate + ? localTargetDateValue + : formatDateForInput(task.target_date); const setTargetDateValue = (value: string) => setLocalTargetDateValue(value); // Start editing - copy current prop value to local state (resolved to slug) @@ -173,7 +193,9 @@ export function TaskMetadata({ task }: TaskMetadataProps) { }; const handleTargetDateSave = async () => { - const newValue = targetDateValue ? new Date(targetDateValue).toISOString() : null; + const newValue = targetDateValue + ? new Date(targetDateValue).toISOString() + : null; if (newValue === task.target_date) { setEditingTargetDate(false); return; @@ -214,13 +236,17 @@ export function TaskMetadata({ task }: TaskMetadataProps) { onValueChange={handlePriorityChange} disabled={updateTask.isPending} > - + {Object.entries(priorityLabels).map(([value, label]) => ( - + {label} @@ -293,7 +319,9 @@ export function TaskMetadata({ task }: TaskMetadataProps) { Created By
- {getAgentDisplayName(task.created_by)} + + {getAgentDisplayName(task.created_by)} + @@ -317,7 +345,9 @@ export function TaskMetadata({ task }: TaskMetadataProps) { Created - {formatRelativeTime(task.created_at)} + + {formatRelativeTime(task.created_at)} + @@ -328,7 +358,9 @@ export function TaskMetadata({ task }: TaskMetadataProps) { Started - {formatRelativeTime(task.started_at)} + + {formatRelativeTime(task.started_at)} + @@ -369,7 +401,9 @@ export function TaskMetadata({ task }: TaskMetadataProps) { Completed - {formatRelativeTime(task.completed_at)} + + {formatRelativeTime(task.completed_at)} + @@ -399,12 +433,15 @@ export function TaskMetadata({ task }: TaskMetadataProps) { : "bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300" } > - {task.nature === TaskNature.TECHNICAL ? "Technical" : "Non-Technical"} + {task.nature === TaskNature.TECHNICAL + ? "Technical" + : "Non-Technical"} - {/* Branch Name - Read-only (all tasks follow git workflow) */} + {/* Branch Name - read-only, but clickable (opens the branch on GitHub) + and copyable. Same look as before; the link/copy are additive. */} {task.branch_name && ( @@ -412,9 +449,26 @@ export function TaskMetadata({ task }: TaskMetadataProps) { Branch - - {task.branch_name} - +
+ {branchHref ? ( + + + {task.branch_name} + + + ) : ( + + {task.branch_name} + + )} + +
)} diff --git a/panel/src/components/tasks/task-table.tsx b/panel/src/components/tasks/task-table.tsx index af66c373..22972b86 100644 --- a/panel/src/components/tasks/task-table.tsx +++ b/panel/src/components/tasks/task-table.tsx @@ -54,7 +54,13 @@ const priorityLabels: Record = { }; // Sorting types - exported for parent components -export type SortField = "title" | "status" | "team" | "priority" | "assigned_to" | "created_at"; +export type SortField = + | "title" + | "status" + | "team" + | "priority" + | "assigned_to" + | "created_at"; export type SortDirection = "asc" | "desc"; interface SortConfig { @@ -67,6 +73,8 @@ interface TaskTableProps { isLoading: boolean; // id -> display name maps for the Project / Product column projectNames?: Record; + // id -> git_url, used to build clickable branch/PR links on the row badge + projectGitUrls?: Record; productNames?: Record; // Controlled sort props (optional for backwards compatibility) sortField?: SortField; @@ -91,7 +99,10 @@ interface TaskTreeNode { depth: number; } -function buildTaskTree(tasks: Task[]): { roots: TaskTreeNode[]; childrenMap: Map } { +function buildTaskTree(tasks: Task[]): { + roots: TaskTreeNode[]; + childrenMap: Map; +} { const taskMap = new Map(); const childrenMap = new Map(); @@ -110,7 +121,7 @@ function buildTaskTree(tasks: Task[]): { roots: TaskTreeNode[]; childrenMap: Map function buildNode(task: Task, depth: number): TaskTreeNode { const children = (childrenMap.get(task.id) || []).map((child) => - buildNode(child, depth + 1) + buildNode(child, depth + 1), ); return { task, children, depth }; } @@ -129,7 +140,7 @@ function buildTaskTree(tasks: Task[]): { roots: TaskTreeNode[]; childrenMap: Map function flattenTree( nodes: TaskTreeNode[], expandedIds: Set, - result: TaskTreeNode[] = [] + result: TaskTreeNode[] = [], ): TaskTreeNode[] { nodes.forEach((node) => { result.push(node); @@ -145,15 +156,33 @@ function TaskTableSkeleton() { <> {Array.from({ length: 5 }).map((_, i) => ( - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + ))} @@ -178,7 +207,13 @@ interface SortableHeaderProps { className?: string; } -function SortableHeader({ label, field, sortConfig, onSort, className }: SortableHeaderProps) { +function SortableHeader({ + label, + field, + sortConfig, + onSort, + className, +}: SortableHeaderProps) { const isActive = sortConfig?.field === field; const direction = isActive ? sortConfig.direction : null; @@ -202,6 +237,7 @@ export function TaskTable({ tasks, isLoading, projectNames = {}, + projectGitUrls = {}, productNames = {}, sortField: controlledSortField, sortDirection: controlledSortDirection, @@ -214,24 +250,35 @@ export function TaskTable({ onExpandedChange, }: TaskTableProps) { // Internal state (used when not controlled) - const [internalSortConfig, setInternalSortConfig] = useState({ - field: "created_at", - direction: "desc", - }); + const [internalSortConfig, setInternalSortConfig] = + useState({ + field: "created_at", + direction: "desc", + }); const [internalCurrentPage, setInternalCurrentPage] = useState(1); const [internalPageSize, setInternalPageSize] = useState(25); - const [internalExpandedIds, setInternalExpandedIds] = useState>(new Set()); + const [internalExpandedIds, setInternalExpandedIds] = useState>( + new Set(), + ); // Use controlled or internal state const isControlled = onSortChange !== undefined; const sortConfig: SortConfig | null = useMemo(() => { if (isControlled) { return controlledSortField - ? { field: controlledSortField, direction: controlledSortDirection || "desc" } + ? { + field: controlledSortField, + direction: controlledSortDirection || "desc", + } : null; } return internalSortConfig; - }, [isControlled, controlledSortField, controlledSortDirection, internalSortConfig]); + }, [ + isControlled, + controlledSortField, + controlledSortDirection, + internalSortConfig, + ]); const currentPage = controlledCurrentPage ?? internalCurrentPage; const pageSize = controlledPageSize ?? internalPageSize; const expandedIds = controlledExpandedIds ?? internalExpandedIds; @@ -292,7 +339,11 @@ export function TaskTable({ const bAssigned = b.task.assigned_to || ""; return multiplier * aAssigned.localeCompare(bAssigned); case "created_at": - return multiplier * (new Date(a.task.created_at).getTime() - new Date(b.task.created_at).getTime()); + return ( + multiplier * + (new Date(a.task.created_at).getTime() - + new Date(b.task.created_at).getTime()) + ); default: return 0; } @@ -385,10 +436,20 @@ export function TaskTable({ {hasAnyChildren && !isLoading && (
Tree view: - -
@@ -418,7 +479,9 @@ export function TaskTable({ onSort={handleSort} className="whitespace-nowrap" /> - Project / Product + + Project / Product + 0 && "bg-muted/20", - hasChildren && "cursor-pointer" + hasChildren && "cursor-pointer", )} onClick={handleRowClick} > @@ -502,12 +565,19 @@ export function TaskTable({ ) : ( )} - +
{task.title} {childCount > 0 && ( - - {childCount} subtask{childCount !== 1 ? "s" : ""} + + {childCount} subtask + {childCount !== 1 ? "s" : ""} )}
@@ -518,7 +588,14 @@ export function TaskTable({ - + {task.team.replace(/_/g, " ")} @@ -536,15 +613,24 @@ export function TaskTable({ )} - + {priorityLabels[task.priority] ?? "P2 - Medium"} - {getAgentDisplayName(task.assigned_to)} + + {getAgentDisplayName(task.assigned_to)} + - {formatDistanceToNow(new Date(task.created_at), { addSuffix: true })} + {formatDistanceToNow(new Date(task.created_at), { + addSuffix: true, + })} @@ -561,7 +647,10 @@ export function TaskTable({
Rows: - diff --git a/panel/src/lib/repo-url.ts b/panel/src/lib/repo-url.ts new file mode 100644 index 00000000..ee1c9199 --- /dev/null +++ b/panel/src/lib/repo-url.ts @@ -0,0 +1,45 @@ +/** + * Helpers to turn a project's stored `git_url` into clickable GitHub-style + * web URLs for branches and PRs. + * + * `git_url` may be an https clone URL (`https://github.com/owner/repo.git`), + * an ssh URL (`git@github.com:owner/repo.git`), or already a web URL — with or + * without a trailing `.git`. Each helper returns `null` when it can't build a + * usable URL, so callers render a plain (non-link) label as a graceful + * fallback rather than a broken link. + */ + +/** Normalize a git_url to its web base, e.g. `https://github.com/owner/repo`. */ +export function repoWebUrl(gitUrl: string | null | undefined): string | null { + if (!gitUrl) return null; + let url = gitUrl.trim(); + // ssh form: git@host:owner/repo(.git) -> https://host/owner/repo + const ssh = url.match(/^git@([^:]+):(.+)$/); + if (ssh) { + url = `https://${ssh[1]}/${ssh[2]}`; + } + url = url.replace(/\.git$/, "").replace(/\/+$/, ""); + return /^https?:\/\//.test(url) ? url : null; +} + +/** Web URL for a branch, e.g. `…/repo/tree/feature/backend/abc`. */ +export function branchUrl( + gitUrl: string | null | undefined, + branch: string | null | undefined, +): string | null { + const base = repoWebUrl(gitUrl); + if (!base || !branch) return null; + // roboco branch names are url-safe ([a-z0-9/_-]); slashes are kept so GitHub + // resolves the full ref path. + return `${base}/tree/${branch}`; +} + +/** Web URL for a pull request, e.g. `…/repo/pull/54`. */ +export function pullUrl( + gitUrl: string | null | undefined, + prNumber: number | null | undefined, +): string | null { + const base = repoWebUrl(gitUrl); + if (!base || !prNumber) return null; + return `${base}/pull/${prNumber}`; +}