mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
feat(panel): clickable Branch/PR links + branch copy button
The Branch value in the task-detail card and the Branch/PR badges in the task list were static text. Make them open the real thing on GitHub, keeping their exact look: - New repo-url helper normalizes a project git_url (https/ssh, with/without .git) into web URLs for a branch (/tree/<branch>) and PR (/pull/<n>), returning null so callers fall back to a plain label. - Task-detail Branch card: the branch is now a link to its GitHub tree URL and gains a copy button (reuses CopyButton); PR was already linked. - List-row git badge (git-status-badge): the PR badge links to task.pr_url (or the built pull URL) and the Branch badge links to the branch tree URL. The row's click handler already ignores <a> clicks, so opening a branch/PR never toggles the row. git_url is threaded via a projectGitUrls map from the tasks page, alongside the existing projectNames map. panel typecheck + eslint clean.
This commit is contained in:
@@ -7,7 +7,13 @@ import { useProjects } from "@/hooks/use-projects";
|
|||||||
import { useProducts } from "@/hooks/use-products";
|
import { useProducts } from "@/hooks/use-products";
|
||||||
import { TaskStatus, Team, TaskType } from "@/types";
|
import { TaskStatus, Team, TaskType } from "@/types";
|
||||||
import { OfflineState } from "@/components/ui/offline-state";
|
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 { Button } from "@/components/ui/button";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { RefreshCw } from "lucide-react";
|
import { RefreshCw } from "lucide-react";
|
||||||
@@ -21,27 +27,27 @@ function TasksPageContent() {
|
|||||||
const statusParam = searchParams.get("status");
|
const statusParam = searchParams.get("status");
|
||||||
const statusFilter = useMemo(
|
const statusFilter = useMemo(
|
||||||
() => (statusParam?.split(",").filter(Boolean) as TaskStatus[]) || [],
|
() => (statusParam?.split(",").filter(Boolean) as TaskStatus[]) || [],
|
||||||
[statusParam]
|
[statusParam],
|
||||||
);
|
);
|
||||||
const teamParam = searchParams.get("team");
|
const teamParam = searchParams.get("team");
|
||||||
const teamFilter = useMemo(
|
const teamFilter = useMemo(
|
||||||
() => (teamParam?.split(",").filter(Boolean) as Team[]) || [],
|
() => (teamParam?.split(",").filter(Boolean) as Team[]) || [],
|
||||||
[teamParam]
|
[teamParam],
|
||||||
);
|
);
|
||||||
const taskTypeParam = searchParams.get("type");
|
const taskTypeParam = searchParams.get("type");
|
||||||
const taskTypeFilter = useMemo(
|
const taskTypeFilter = useMemo(
|
||||||
() => (taskTypeParam?.split(",").filter(Boolean) as TaskType[]) || [],
|
() => (taskTypeParam?.split(",").filter(Boolean) as TaskType[]) || [],
|
||||||
[taskTypeParam]
|
[taskTypeParam],
|
||||||
);
|
);
|
||||||
const projectParam = searchParams.get("project");
|
const projectParam = searchParams.get("project");
|
||||||
const projectFilter = useMemo(
|
const projectFilter = useMemo(
|
||||||
() => projectParam?.split(",").filter(Boolean) || [],
|
() => projectParam?.split(",").filter(Boolean) || [],
|
||||||
[projectParam]
|
[projectParam],
|
||||||
);
|
);
|
||||||
const productParam = searchParams.get("product");
|
const productParam = searchParams.get("product");
|
||||||
const productFilter = useMemo(
|
const productFilter = useMemo(
|
||||||
() => productParam?.split(",").filter(Boolean) || [],
|
() => productParam?.split(",").filter(Boolean) || [],
|
||||||
[productParam]
|
[productParam],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Table state from URL
|
// Table state from URL
|
||||||
@@ -52,71 +58,106 @@ function TasksPageContent() {
|
|||||||
const expandedParam = searchParams.get("expanded");
|
const expandedParam = searchParams.get("expanded");
|
||||||
const expandedIds = useMemo(
|
const expandedIds = useMemo(
|
||||||
() => new Set(expandedParam?.split(",").filter(Boolean) || []),
|
() => new Set(expandedParam?.split(",").filter(Boolean) || []),
|
||||||
[expandedParam]
|
[expandedParam],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Update URL params
|
// Update URL params
|
||||||
const updateParams = useCallback((updates: Record<string, string | null>) => {
|
const updateParams = useCallback(
|
||||||
const params = new URLSearchParams(searchParams.toString());
|
(updates: Record<string, string | null>) => {
|
||||||
Object.entries(updates).forEach(([key, value]) => {
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
if (value) {
|
Object.entries(updates).forEach(([key, value]) => {
|
||||||
params.set(key, value);
|
if (value) {
|
||||||
} else {
|
params.set(key, value);
|
||||||
params.delete(key);
|
} else {
|
||||||
}
|
params.delete(key);
|
||||||
});
|
}
|
||||||
const query = params.toString();
|
});
|
||||||
router.push(query ? `/tasks?${query}` : "/tasks");
|
const query = params.toString();
|
||||||
}, [router, searchParams]);
|
router.push(query ? `/tasks?${query}` : "/tasks");
|
||||||
|
},
|
||||||
|
[router, searchParams],
|
||||||
|
);
|
||||||
|
|
||||||
const handleSearchChange = useCallback((value: string) => {
|
const handleSearchChange = useCallback(
|
||||||
updateParams({ q: value || null });
|
(value: string) => {
|
||||||
}, [updateParams]);
|
updateParams({ q: value || null });
|
||||||
|
},
|
||||||
|
[updateParams],
|
||||||
|
);
|
||||||
|
|
||||||
const handleStatusChange = useCallback((value: TaskStatus[]) => {
|
const handleStatusChange = useCallback(
|
||||||
updateParams({ status: value.length > 0 ? value.join(",") : null });
|
(value: TaskStatus[]) => {
|
||||||
}, [updateParams]);
|
updateParams({ status: value.length > 0 ? value.join(",") : null });
|
||||||
|
},
|
||||||
|
[updateParams],
|
||||||
|
);
|
||||||
|
|
||||||
const handleTeamChange = useCallback((value: Team[]) => {
|
const handleTeamChange = useCallback(
|
||||||
updateParams({ team: value.length > 0 ? value.join(",") : null });
|
(value: Team[]) => {
|
||||||
}, [updateParams]);
|
updateParams({ team: value.length > 0 ? value.join(",") : null });
|
||||||
|
},
|
||||||
|
[updateParams],
|
||||||
|
);
|
||||||
|
|
||||||
const handleTaskTypeChange = useCallback((value: TaskType[]) => {
|
const handleTaskTypeChange = useCallback(
|
||||||
updateParams({ type: value.length > 0 ? value.join(",") : null });
|
(value: TaskType[]) => {
|
||||||
}, [updateParams]);
|
updateParams({ type: value.length > 0 ? value.join(",") : null });
|
||||||
|
},
|
||||||
|
[updateParams],
|
||||||
|
);
|
||||||
|
|
||||||
const handleProjectChange = useCallback((value: string[]) => {
|
const handleProjectChange = useCallback(
|
||||||
updateParams({ project: value.length > 0 ? value.join(",") : null });
|
(value: string[]) => {
|
||||||
}, [updateParams]);
|
updateParams({ project: value.length > 0 ? value.join(",") : null });
|
||||||
|
},
|
||||||
|
[updateParams],
|
||||||
|
);
|
||||||
|
|
||||||
const handleProductChange = useCallback((value: string[]) => {
|
const handleProductChange = useCallback(
|
||||||
updateParams({ product: value.length > 0 ? value.join(",") : null });
|
(value: string[]) => {
|
||||||
}, [updateParams]);
|
updateParams({ product: value.length > 0 ? value.join(",") : null });
|
||||||
|
},
|
||||||
|
[updateParams],
|
||||||
|
);
|
||||||
|
|
||||||
// Table state handlers
|
// Table state handlers
|
||||||
const handleSortChange = useCallback((field: SortField, direction: SortDirection | null) => {
|
const handleSortChange = useCallback(
|
||||||
if (direction === null) {
|
(field: SortField, direction: SortDirection | null) => {
|
||||||
updateParams({ sortBy: null, sortDir: null, page: null });
|
if (direction === null) {
|
||||||
} else {
|
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<string>) => {
|
||||||
updateParams({
|
updateParams({
|
||||||
sortBy: field === "created_at" ? null : field,
|
expanded: ids.size > 0 ? Array.from(ids).join(",") : null,
|
||||||
sortDir: direction === "desc" ? null : direction,
|
|
||||||
page: null,
|
|
||||||
});
|
});
|
||||||
}
|
},
|
||||||
}, [updateParams]);
|
[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<string>) => {
|
|
||||||
updateParams({ expanded: ids.size > 0 ? Array.from(ids).join(",") : null });
|
|
||||||
}, [updateParams]);
|
|
||||||
|
|
||||||
// Fetch all tasks and filter client-side for multi-select
|
// Fetch all tasks and filter client-side for multi-select
|
||||||
const { data: tasks, isLoading, error, refetch } = useTasks();
|
const { data: tasks, isLoading, error, refetch } = useTasks();
|
||||||
@@ -126,19 +167,23 @@ function TasksPageContent() {
|
|||||||
const { data: products } = useProducts();
|
const { data: products } = useProducts();
|
||||||
const projectNames = useMemo(
|
const projectNames = useMemo(
|
||||||
() => Object.fromEntries((projects ?? []).map((p) => [p.id, p.name])),
|
() => 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(
|
const productNames = useMemo(
|
||||||
() => Object.fromEntries((products ?? []).map((p) => [p.id, p.name])),
|
() => Object.fromEntries((products ?? []).map((p) => [p.id, p.name])),
|
||||||
[products]
|
[products],
|
||||||
);
|
);
|
||||||
const projectOptions = useMemo(
|
const projectOptions = useMemo(
|
||||||
() => (projects ?? []).map((p) => ({ value: p.id, label: p.name })),
|
() => (projects ?? []).map((p) => ({ value: p.id, label: p.name })),
|
||||||
[projects]
|
[projects],
|
||||||
);
|
);
|
||||||
const productOptions = useMemo(
|
const productOptions = useMemo(
|
||||||
() => (products ?? []).map((p) => ({ value: p.id, label: p.name })),
|
() => (products ?? []).map((p) => ({ value: p.id, label: p.name })),
|
||||||
[products]
|
[products],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Filter tasks based on multi-select filters
|
// Filter tasks based on multi-select filters
|
||||||
@@ -147,7 +192,10 @@ function TasksPageContent() {
|
|||||||
|
|
||||||
return tasks.filter((task) => {
|
return tasks.filter((task) => {
|
||||||
// Search filter
|
// Search filter
|
||||||
if (searchQuery && !task.title.toLowerCase().includes(searchQuery.toLowerCase())) {
|
if (
|
||||||
|
searchQuery &&
|
||||||
|
!task.title.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,30 +211,48 @@ function TasksPageContent() {
|
|||||||
|
|
||||||
// Task type filter (if any selected, task must match one of them)
|
// Task type filter (if any selected, task must match one of them)
|
||||||
// Note: task_type may be undefined until backend adds it to TaskResponse
|
// 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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Project filter (a task with no project_id is excluded when filtering by project)
|
// 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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Product filter (a task with no product_id is excluded when filtering by product)
|
// 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 false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
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)
|
// Check if it's a connection error (backend not running)
|
||||||
const isOffline = error && (
|
const isOffline =
|
||||||
error.message?.includes("Network Error") ||
|
error &&
|
||||||
error.message?.includes("ECONNREFUSED") ||
|
(error.message?.includes("Network Error") ||
|
||||||
(error as { code?: string })?.code === "ERR_NETWORK"
|
error.message?.includes("ECONNREFUSED") ||
|
||||||
);
|
(error as { code?: string })?.code === "ERR_NETWORK");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -239,6 +305,7 @@ function TasksPageContent() {
|
|||||||
tasks={filteredTasks}
|
tasks={filteredTasks}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
projectNames={projectNames}
|
projectNames={projectNames}
|
||||||
|
projectGitUrls={projectGitUrls}
|
||||||
productNames={productNames}
|
productNames={productNames}
|
||||||
sortField={sortField}
|
sortField={sortField}
|
||||||
sortDirection={sortDir}
|
sortDirection={sortDir}
|
||||||
@@ -258,18 +325,20 @@ function TasksPageContent() {
|
|||||||
// Wrap in Suspense for useSearchParams
|
// Wrap in Suspense for useSearchParams
|
||||||
export default function TasksPage() {
|
export default function TasksPage() {
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={
|
<Suspense
|
||||||
<div className="space-y-6">
|
fallback={
|
||||||
<div className="flex items-center justify-between">
|
<div className="space-y-6">
|
||||||
<div>
|
<div className="flex items-center justify-between">
|
||||||
<Skeleton className="h-9 w-32 mb-2" />
|
<div>
|
||||||
<Skeleton className="h-5 w-64" />
|
<Skeleton className="h-9 w-32 mb-2" />
|
||||||
|
<Skeleton className="h-5 w-64" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<Skeleton className="h-12 w-full" />
|
||||||
|
<Skeleton className="h-96 w-full" />
|
||||||
</div>
|
</div>
|
||||||
<Skeleton className="h-12 w-full" />
|
}
|
||||||
<Skeleton className="h-96 w-full" />
|
>
|
||||||
</div>
|
|
||||||
}>
|
|
||||||
<TasksPageContent />
|
<TasksPageContent />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,24 +1,61 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import type { ReactNode } from "react";
|
||||||
import { GitBranch, GitPullRequest, FileCheck } from "lucide-react";
|
import { GitBranch, GitPullRequest, FileCheck } from "lucide-react";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Task, TaskStatus } from "@/types";
|
import { Task, TaskStatus } from "@/types";
|
||||||
|
import { branchUrl, pullUrl } from "@/lib/repo-url";
|
||||||
|
|
||||||
interface GitStatusBadgeProps {
|
interface GitStatusBadgeProps {
|
||||||
task: Task;
|
task: Task;
|
||||||
compact?: boolean;
|
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 `<a>` 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 (
|
||||||
|
<a
|
||||||
|
href={href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
title="Open in GitHub"
|
||||||
|
className="inline-flex transition-opacity hover:opacity-80"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GitStatusBadge({
|
||||||
|
task,
|
||||||
|
compact = true,
|
||||||
|
repoUrl,
|
||||||
|
}: GitStatusBadgeProps) {
|
||||||
// All tasks follow git workflow - show relevant status
|
// All tasks follow git workflow - show relevant status
|
||||||
|
|
||||||
// Show PR badge with status (highest priority)
|
// Show PR badge with status (highest priority)
|
||||||
if (task.pr_number) {
|
if (task.pr_number) {
|
||||||
return (
|
return (
|
||||||
<Badge className="gap-1 text-xs bg-purple-500/10 text-purple-600 dark:text-purple-400">
|
<MaybeLink href={task.pr_url ?? pullUrl(repoUrl, task.pr_number)}>
|
||||||
<GitPullRequest className="h-3 w-3" />
|
<Badge className="gap-1 text-xs bg-purple-500/10 text-purple-600 dark:text-purple-400">
|
||||||
PR #{task.pr_number}
|
<GitPullRequest className="h-3 w-3" />
|
||||||
</Badge>
|
PR #{task.pr_number}
|
||||||
|
</Badge>
|
||||||
|
</MaybeLink>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,10 +92,12 @@ export function GitStatusBadge({ task, compact = true }: GitStatusBadgeProps) {
|
|||||||
// Show branch badge (when branch exists but no PR yet)
|
// Show branch badge (when branch exists but no PR yet)
|
||||||
if (task.branch_name) {
|
if (task.branch_name) {
|
||||||
return (
|
return (
|
||||||
<Badge variant="outline" className="gap-1 text-xs">
|
<MaybeLink href={branchUrl(repoUrl, task.branch_name)}>
|
||||||
<GitBranch className="h-3 w-3" />
|
<Badge variant="outline" className="gap-1 text-xs">
|
||||||
{compact ? "Branch" : task.branch_name}
|
<GitBranch className="h-3 w-3" />
|
||||||
</Badge>
|
{compact ? "Branch" : task.branch_name}
|
||||||
|
</Badge>
|
||||||
|
</MaybeLink>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,9 +14,24 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} 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 { toast } from "sonner";
|
||||||
import { getAgentDisplayName, resolveToSlug } from "@/lib/agent-utils";
|
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 { TaskTypeBadge } from "../task-type-badge";
|
||||||
import { DocsStatusBadge } from "../docs-status-badge";
|
import { DocsStatusBadge } from "../docs-status-badge";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
@@ -80,6 +95,7 @@ function formatDateForInput(date: string | null): string {
|
|||||||
export function TaskMetadata({ task }: TaskMetadataProps) {
|
export function TaskMetadata({ task }: TaskMetadataProps) {
|
||||||
const updateTask = useUpdateTask();
|
const updateTask = useUpdateTask();
|
||||||
const { data: project } = useProject(task.project_id ?? "");
|
const { data: project } = useProject(task.project_id ?? "");
|
||||||
|
const branchHref = branchUrl(project?.git_url, task.branch_name);
|
||||||
|
|
||||||
// Editing states - use local state only while editing
|
// Editing states - use local state only while editing
|
||||||
const [editingAssigned, setEditingAssigned] = useState(false);
|
const [editingAssigned, setEditingAssigned] = useState(false);
|
||||||
@@ -91,10 +107,14 @@ export function TaskMetadata({ task }: TaskMetadataProps) {
|
|||||||
const targetDateInputRef = useRef<HTMLInputElement>(null);
|
const targetDateInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
// Display prop value when not editing, local value when editing
|
// 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 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);
|
const setTargetDateValue = (value: string) => setLocalTargetDateValue(value);
|
||||||
|
|
||||||
// Start editing - copy current prop value to local state (resolved to slug)
|
// 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 handleTargetDateSave = async () => {
|
||||||
const newValue = targetDateValue ? new Date(targetDateValue).toISOString() : null;
|
const newValue = targetDateValue
|
||||||
|
? new Date(targetDateValue).toISOString()
|
||||||
|
: null;
|
||||||
if (newValue === task.target_date) {
|
if (newValue === task.target_date) {
|
||||||
setEditingTargetDate(false);
|
setEditingTargetDate(false);
|
||||||
return;
|
return;
|
||||||
@@ -214,13 +236,17 @@ export function TaskMetadata({ task }: TaskMetadataProps) {
|
|||||||
onValueChange={handlePriorityChange}
|
onValueChange={handlePriorityChange}
|
||||||
disabled={updateTask.isPending}
|
disabled={updateTask.isPending}
|
||||||
>
|
>
|
||||||
<SelectTrigger className={`w-full h-8 text-sm border-0 ${priorityColors[task.priority] ?? priorityColors[2]}`}>
|
<SelectTrigger
|
||||||
|
className={`w-full h-8 text-sm border-0 ${priorityColors[task.priority] ?? priorityColors[2]}`}
|
||||||
|
>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{Object.entries(priorityLabels).map(([value, label]) => (
|
{Object.entries(priorityLabels).map(([value, label]) => (
|
||||||
<SelectItem key={value} value={value}>
|
<SelectItem key={value} value={value}>
|
||||||
<span className={`px-2 py-0.5 rounded ${priorityColors[parseInt(value)]}`}>
|
<span
|
||||||
|
className={`px-2 py-0.5 rounded ${priorityColors[parseInt(value)]}`}
|
||||||
|
>
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
@@ -293,7 +319,9 @@ export function TaskMetadata({ task }: TaskMetadataProps) {
|
|||||||
<User className="h-4 w-4" />
|
<User className="h-4 w-4" />
|
||||||
Created By
|
Created By
|
||||||
</div>
|
</div>
|
||||||
<span className="font-medium">{getAgentDisplayName(task.created_by)}</span>
|
<span className="font-medium">
|
||||||
|
{getAgentDisplayName(task.created_by)}
|
||||||
|
</span>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -317,7 +345,9 @@ export function TaskMetadata({ task }: TaskMetadataProps) {
|
|||||||
<Calendar className="h-4 w-4" />
|
<Calendar className="h-4 w-4" />
|
||||||
Created
|
Created
|
||||||
</div>
|
</div>
|
||||||
<span className="font-medium">{formatRelativeTime(task.created_at)}</span>
|
<span className="font-medium">
|
||||||
|
{formatRelativeTime(task.created_at)}
|
||||||
|
</span>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -328,7 +358,9 @@ export function TaskMetadata({ task }: TaskMetadataProps) {
|
|||||||
<Clock className="h-4 w-4" />
|
<Clock className="h-4 w-4" />
|
||||||
Started
|
Started
|
||||||
</div>
|
</div>
|
||||||
<span className="font-medium">{formatRelativeTime(task.started_at)}</span>
|
<span className="font-medium">
|
||||||
|
{formatRelativeTime(task.started_at)}
|
||||||
|
</span>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -369,7 +401,9 @@ export function TaskMetadata({ task }: TaskMetadataProps) {
|
|||||||
<Clock className="h-4 w-4" />
|
<Clock className="h-4 w-4" />
|
||||||
Completed
|
Completed
|
||||||
</div>
|
</div>
|
||||||
<span className="font-medium">{formatRelativeTime(task.completed_at)}</span>
|
<span className="font-medium">
|
||||||
|
{formatRelativeTime(task.completed_at)}
|
||||||
|
</span>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -399,12 +433,15 @@ export function TaskMetadata({ task }: TaskMetadataProps) {
|
|||||||
: "bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300"
|
: "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"}
|
||||||
</Badge>
|
</Badge>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* 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 && (
|
{task.branch_name && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="pt-4">
|
<CardContent className="pt-4">
|
||||||
@@ -412,9 +449,26 @@ export function TaskMetadata({ task }: TaskMetadataProps) {
|
|||||||
<GitBranch className="h-4 w-4" />
|
<GitBranch className="h-4 w-4" />
|
||||||
Branch
|
Branch
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="outline" className="font-mono text-xs">
|
<div className="flex items-center gap-1">
|
||||||
{task.branch_name}
|
{branchHref ? (
|
||||||
</Badge>
|
<a
|
||||||
|
href={branchHref}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
title="Open branch in GitHub"
|
||||||
|
className="inline-flex transition-opacity hover:opacity-80"
|
||||||
|
>
|
||||||
|
<Badge variant="outline" className="font-mono text-xs">
|
||||||
|
{task.branch_name}
|
||||||
|
</Badge>
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<Badge variant="outline" className="font-mono text-xs">
|
||||||
|
{task.branch_name}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
<CopyButton value={task.branch_name} />
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -54,7 +54,13 @@ const priorityLabels: Record<number, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Sorting types - exported for parent components
|
// 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";
|
export type SortDirection = "asc" | "desc";
|
||||||
|
|
||||||
interface SortConfig {
|
interface SortConfig {
|
||||||
@@ -67,6 +73,8 @@ interface TaskTableProps {
|
|||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
// id -> display name maps for the Project / Product column
|
// id -> display name maps for the Project / Product column
|
||||||
projectNames?: Record<string, string>;
|
projectNames?: Record<string, string>;
|
||||||
|
// id -> git_url, used to build clickable branch/PR links on the row badge
|
||||||
|
projectGitUrls?: Record<string, string>;
|
||||||
productNames?: Record<string, string>;
|
productNames?: Record<string, string>;
|
||||||
// Controlled sort props (optional for backwards compatibility)
|
// Controlled sort props (optional for backwards compatibility)
|
||||||
sortField?: SortField;
|
sortField?: SortField;
|
||||||
@@ -91,7 +99,10 @@ interface TaskTreeNode {
|
|||||||
depth: number;
|
depth: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildTaskTree(tasks: Task[]): { roots: TaskTreeNode[]; childrenMap: Map<string, Task[]> } {
|
function buildTaskTree(tasks: Task[]): {
|
||||||
|
roots: TaskTreeNode[];
|
||||||
|
childrenMap: Map<string, Task[]>;
|
||||||
|
} {
|
||||||
const taskMap = new Map<string, Task>();
|
const taskMap = new Map<string, Task>();
|
||||||
const childrenMap = new Map<string, Task[]>();
|
const childrenMap = new Map<string, Task[]>();
|
||||||
|
|
||||||
@@ -110,7 +121,7 @@ function buildTaskTree(tasks: Task[]): { roots: TaskTreeNode[]; childrenMap: Map
|
|||||||
|
|
||||||
function buildNode(task: Task, depth: number): TaskTreeNode {
|
function buildNode(task: Task, depth: number): TaskTreeNode {
|
||||||
const children = (childrenMap.get(task.id) || []).map((child) =>
|
const children = (childrenMap.get(task.id) || []).map((child) =>
|
||||||
buildNode(child, depth + 1)
|
buildNode(child, depth + 1),
|
||||||
);
|
);
|
||||||
return { task, children, depth };
|
return { task, children, depth };
|
||||||
}
|
}
|
||||||
@@ -129,7 +140,7 @@ function buildTaskTree(tasks: Task[]): { roots: TaskTreeNode[]; childrenMap: Map
|
|||||||
function flattenTree(
|
function flattenTree(
|
||||||
nodes: TaskTreeNode[],
|
nodes: TaskTreeNode[],
|
||||||
expandedIds: Set<string>,
|
expandedIds: Set<string>,
|
||||||
result: TaskTreeNode[] = []
|
result: TaskTreeNode[] = [],
|
||||||
): TaskTreeNode[] {
|
): TaskTreeNode[] {
|
||||||
nodes.forEach((node) => {
|
nodes.forEach((node) => {
|
||||||
result.push(node);
|
result.push(node);
|
||||||
@@ -145,15 +156,33 @@ function TaskTableSkeleton() {
|
|||||||
<>
|
<>
|
||||||
{Array.from({ length: 5 }).map((_, i) => (
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
<TableRow key={i}>
|
<TableRow key={i}>
|
||||||
<TableCell><Skeleton className="h-4 w-3/4" /></TableCell>
|
<TableCell>
|
||||||
<TableCell className="whitespace-nowrap"><Skeleton className="h-6 w-16" /></TableCell>
|
<Skeleton className="h-4 w-3/4" />
|
||||||
<TableCell className="whitespace-nowrap"><Skeleton className="h-6 w-12" /></TableCell>
|
</TableCell>
|
||||||
<TableCell className="whitespace-nowrap"><Skeleton className="h-4 w-14" /></TableCell>
|
<TableCell className="whitespace-nowrap">
|
||||||
<TableCell className="whitespace-nowrap"><Skeleton className="h-4 w-24" /></TableCell>
|
<Skeleton className="h-6 w-16" />
|
||||||
<TableCell className="whitespace-nowrap"><Skeleton className="h-4 w-8" /></TableCell>
|
</TableCell>
|
||||||
<TableCell className="whitespace-nowrap"><Skeleton className="h-4 w-20" /></TableCell>
|
<TableCell className="whitespace-nowrap">
|
||||||
<TableCell className="whitespace-nowrap"><Skeleton className="h-4 w-16" /></TableCell>
|
<Skeleton className="h-6 w-12" />
|
||||||
<TableCell><Skeleton className="h-8 w-8" /></TableCell>
|
</TableCell>
|
||||||
|
<TableCell className="whitespace-nowrap">
|
||||||
|
<Skeleton className="h-4 w-14" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="whitespace-nowrap">
|
||||||
|
<Skeleton className="h-4 w-24" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="whitespace-nowrap">
|
||||||
|
<Skeleton className="h-4 w-8" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="whitespace-nowrap">
|
||||||
|
<Skeleton className="h-4 w-20" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="whitespace-nowrap">
|
||||||
|
<Skeleton className="h-4 w-16" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-8 w-8" />
|
||||||
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
@@ -178,7 +207,13 @@ interface SortableHeaderProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SortableHeader({ label, field, sortConfig, onSort, className }: SortableHeaderProps) {
|
function SortableHeader({
|
||||||
|
label,
|
||||||
|
field,
|
||||||
|
sortConfig,
|
||||||
|
onSort,
|
||||||
|
className,
|
||||||
|
}: SortableHeaderProps) {
|
||||||
const isActive = sortConfig?.field === field;
|
const isActive = sortConfig?.field === field;
|
||||||
const direction = isActive ? sortConfig.direction : null;
|
const direction = isActive ? sortConfig.direction : null;
|
||||||
|
|
||||||
@@ -202,6 +237,7 @@ export function TaskTable({
|
|||||||
tasks,
|
tasks,
|
||||||
isLoading,
|
isLoading,
|
||||||
projectNames = {},
|
projectNames = {},
|
||||||
|
projectGitUrls = {},
|
||||||
productNames = {},
|
productNames = {},
|
||||||
sortField: controlledSortField,
|
sortField: controlledSortField,
|
||||||
sortDirection: controlledSortDirection,
|
sortDirection: controlledSortDirection,
|
||||||
@@ -214,24 +250,35 @@ export function TaskTable({
|
|||||||
onExpandedChange,
|
onExpandedChange,
|
||||||
}: TaskTableProps) {
|
}: TaskTableProps) {
|
||||||
// Internal state (used when not controlled)
|
// Internal state (used when not controlled)
|
||||||
const [internalSortConfig, setInternalSortConfig] = useState<SortConfig | null>({
|
const [internalSortConfig, setInternalSortConfig] =
|
||||||
field: "created_at",
|
useState<SortConfig | null>({
|
||||||
direction: "desc",
|
field: "created_at",
|
||||||
});
|
direction: "desc",
|
||||||
|
});
|
||||||
const [internalCurrentPage, setInternalCurrentPage] = useState(1);
|
const [internalCurrentPage, setInternalCurrentPage] = useState(1);
|
||||||
const [internalPageSize, setInternalPageSize] = useState(25);
|
const [internalPageSize, setInternalPageSize] = useState(25);
|
||||||
const [internalExpandedIds, setInternalExpandedIds] = useState<Set<string>>(new Set());
|
const [internalExpandedIds, setInternalExpandedIds] = useState<Set<string>>(
|
||||||
|
new Set(),
|
||||||
|
);
|
||||||
|
|
||||||
// Use controlled or internal state
|
// Use controlled or internal state
|
||||||
const isControlled = onSortChange !== undefined;
|
const isControlled = onSortChange !== undefined;
|
||||||
const sortConfig: SortConfig | null = useMemo(() => {
|
const sortConfig: SortConfig | null = useMemo(() => {
|
||||||
if (isControlled) {
|
if (isControlled) {
|
||||||
return controlledSortField
|
return controlledSortField
|
||||||
? { field: controlledSortField, direction: controlledSortDirection || "desc" }
|
? {
|
||||||
|
field: controlledSortField,
|
||||||
|
direction: controlledSortDirection || "desc",
|
||||||
|
}
|
||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
return internalSortConfig;
|
return internalSortConfig;
|
||||||
}, [isControlled, controlledSortField, controlledSortDirection, internalSortConfig]);
|
}, [
|
||||||
|
isControlled,
|
||||||
|
controlledSortField,
|
||||||
|
controlledSortDirection,
|
||||||
|
internalSortConfig,
|
||||||
|
]);
|
||||||
const currentPage = controlledCurrentPage ?? internalCurrentPage;
|
const currentPage = controlledCurrentPage ?? internalCurrentPage;
|
||||||
const pageSize = controlledPageSize ?? internalPageSize;
|
const pageSize = controlledPageSize ?? internalPageSize;
|
||||||
const expandedIds = controlledExpandedIds ?? internalExpandedIds;
|
const expandedIds = controlledExpandedIds ?? internalExpandedIds;
|
||||||
@@ -292,7 +339,11 @@ export function TaskTable({
|
|||||||
const bAssigned = b.task.assigned_to || "";
|
const bAssigned = b.task.assigned_to || "";
|
||||||
return multiplier * aAssigned.localeCompare(bAssigned);
|
return multiplier * aAssigned.localeCompare(bAssigned);
|
||||||
case "created_at":
|
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:
|
default:
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
@@ -385,10 +436,20 @@ export function TaskTable({
|
|||||||
{hasAnyChildren && !isLoading && (
|
{hasAnyChildren && !isLoading && (
|
||||||
<div className="flex items-center gap-2 px-4 py-2 border-b bg-muted/30">
|
<div className="flex items-center gap-2 px-4 py-2 border-b bg-muted/30">
|
||||||
<span className="text-sm text-muted-foreground">Tree view:</span>
|
<span className="text-sm text-muted-foreground">Tree view:</span>
|
||||||
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={expandAll}>
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 text-xs"
|
||||||
|
onClick={expandAll}
|
||||||
|
>
|
||||||
Expand all
|
Expand all
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={collapseAll}>
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 text-xs"
|
||||||
|
onClick={collapseAll}
|
||||||
|
>
|
||||||
Collapse all
|
Collapse all
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -418,7 +479,9 @@ export function TaskTable({
|
|||||||
onSort={handleSort}
|
onSort={handleSort}
|
||||||
className="whitespace-nowrap"
|
className="whitespace-nowrap"
|
||||||
/>
|
/>
|
||||||
<TableHead className="whitespace-nowrap">Project / Product</TableHead>
|
<TableHead className="whitespace-nowrap">
|
||||||
|
Project / Product
|
||||||
|
</TableHead>
|
||||||
<SortableHeader
|
<SortableHeader
|
||||||
label="Priority"
|
label="Priority"
|
||||||
field="priority"
|
field="priority"
|
||||||
@@ -462,7 +525,7 @@ export function TaskTable({
|
|||||||
target.closest("a") ||
|
target.closest("a") ||
|
||||||
target.closest("button") ||
|
target.closest("button") ||
|
||||||
target.closest('[role="button"]') ||
|
target.closest('[role="button"]') ||
|
||||||
target.closest('[data-no-expand]')
|
target.closest("[data-no-expand]")
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -477,7 +540,7 @@ export function TaskTable({
|
|||||||
className={cn(
|
className={cn(
|
||||||
"hover:bg-muted/50",
|
"hover:bg-muted/50",
|
||||||
node.depth > 0 && "bg-muted/20",
|
node.depth > 0 && "bg-muted/20",
|
||||||
hasChildren && "cursor-pointer"
|
hasChildren && "cursor-pointer",
|
||||||
)}
|
)}
|
||||||
onClick={handleRowClick}
|
onClick={handleRowClick}
|
||||||
>
|
>
|
||||||
@@ -502,12 +565,19 @@ export function TaskTable({
|
|||||||
) : (
|
) : (
|
||||||
<span className="w-5 shrink-0" />
|
<span className="w-5 shrink-0" />
|
||||||
)}
|
)}
|
||||||
<Link href={"/tasks/" + task.id} className="block hover:underline min-w-0">
|
<Link
|
||||||
|
href={"/tasks/" + task.id}
|
||||||
|
className="block hover:underline min-w-0"
|
||||||
|
>
|
||||||
<div className="font-medium flex items-center gap-2">
|
<div className="font-medium flex items-center gap-2">
|
||||||
<span className="truncate">{task.title}</span>
|
<span className="truncate">{task.title}</span>
|
||||||
{childCount > 0 && (
|
{childCount > 0 && (
|
||||||
<Badge variant="secondary" className="text-xs shrink-0">
|
<Badge
|
||||||
{childCount} subtask{childCount !== 1 ? "s" : ""}
|
variant="secondary"
|
||||||
|
className="text-xs shrink-0"
|
||||||
|
>
|
||||||
|
{childCount} subtask
|
||||||
|
{childCount !== 1 ? "s" : ""}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -518,7 +588,14 @@ export function TaskTable({
|
|||||||
<TaskStatusBadge status={task.status} />
|
<TaskStatusBadge status={task.status} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="whitespace-nowrap">
|
<TableCell className="whitespace-nowrap">
|
||||||
<GitStatusBadge task={task} />
|
<GitStatusBadge
|
||||||
|
task={task}
|
||||||
|
repoUrl={
|
||||||
|
task.project_id
|
||||||
|
? projectGitUrls[task.project_id]
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="capitalize whitespace-nowrap">
|
<TableCell className="capitalize whitespace-nowrap">
|
||||||
{task.team.replace(/_/g, " ")}
|
{task.team.replace(/_/g, " ")}
|
||||||
@@ -536,15 +613,24 @@ export function TaskTable({
|
|||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="whitespace-nowrap">
|
<TableCell className="whitespace-nowrap">
|
||||||
<Badge className={(priorityColors[task.priority] ?? priorityColors[2]) + " text-xs"}>
|
<Badge
|
||||||
|
className={
|
||||||
|
(priorityColors[task.priority] ?? priorityColors[2]) +
|
||||||
|
" text-xs"
|
||||||
|
}
|
||||||
|
>
|
||||||
{priorityLabels[task.priority] ?? "P2 - Medium"}
|
{priorityLabels[task.priority] ?? "P2 - Medium"}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="whitespace-nowrap">
|
<TableCell className="whitespace-nowrap">
|
||||||
<Badge variant="outline">{getAgentDisplayName(task.assigned_to)}</Badge>
|
<Badge variant="outline">
|
||||||
|
{getAgentDisplayName(task.assigned_to)}
|
||||||
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-muted-foreground text-sm whitespace-nowrap">
|
<TableCell className="text-muted-foreground text-sm whitespace-nowrap">
|
||||||
{formatDistanceToNow(new Date(task.created_at), { addSuffix: true })}
|
{formatDistanceToNow(new Date(task.created_at), {
|
||||||
|
addSuffix: true,
|
||||||
|
})}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<TaskActions task={task} />
|
<TaskActions task={task} />
|
||||||
@@ -561,7 +647,10 @@ export function TaskTable({
|
|||||||
<div className="flex items-center justify-end gap-4 px-4 py-3 border-t">
|
<div className="flex items-center justify-end gap-4 px-4 py-3 border-t">
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
<span>Rows:</span>
|
<span>Rows:</span>
|
||||||
<Select value={String(pageSize)} onValueChange={handlePageSizeChange}>
|
<Select
|
||||||
|
value={String(pageSize)}
|
||||||
|
onValueChange={handlePageSizeChange}
|
||||||
|
>
|
||||||
<SelectTrigger className="w-auto min-w-14 h-8">
|
<SelectTrigger className="w-auto min-w-14 h-8">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
|
|||||||
@@ -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}`;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user