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:
Renn F
2026-06-21 07:15:58 +02:00
parent c3ee5f09ba
commit 0740dc141e
5 changed files with 437 additions and 141 deletions
+151 -82
View File
@@ -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>
)} )}
+124 -35
View File
@@ -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>
+45
View File
@@ -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}`;
}