Task and attempt relationships (#1083)
* Display parent task attempt (vibe-kanban 13d990e8) If there is a parent task attempt, display a box with summary info about it and allow the user to navigate to that frontend/src/components/panels/TaskPanel.tsx * If parent task attempt, show icon in kanban card (vibe-kanban 806790be) If task has a parent task attempt, it should be visible and also easy to navigate to the parent from the kanban card Let's place an arrow-up icon to the left of the burger menu icon, when there is a parent task attempt. Clicking this should navigate to the task attempt frontend/src/components/tasks/TaskKanbanBoard.tsx * Popup to view related tasks for the task attempt (vibe-kanban b77eeb33) frontend/src/components/panels/AttemptHeaderActions.tsx Should be possible to view tasks that have parent_task_attempt set to this task. Similar to frontend/src/components/dialogs/tasks/ViewProcessesDialog.tsx * i18n * Improve related tasks (vibe-kanban) (#1166) * Excellent! All checks are passing. Let me create a quick summary of what was changed: ## Summary I've successfully fixed the related tasks list to show both parent and child tasks. Here's what was changed: ### Changes Made 1. **`frontend/src/hooks/useTaskChildren.ts`** - Renamed `useTaskChildren` to `useTaskRelationships` (kept old export for backwards compatibility) - Changed return type from `Task[]` to `TaskRelationships` - Now returns the full relationships object including `parent_task`, `current_attempt`, and `children` - Updated query keys from `taskChildren` to `taskRelationships` 2. **`frontend/src/components/dialogs/tasks/ViewRelatedTasksDialog.tsx`** - Updated to use `useTaskRelationships` hook - Combines parent and children into a single `relatedTasks` array - Now displays both parent and child tasks in the dialog 3. **`frontend/src/hooks/useTaskMutations.ts`** - Updated imports to use `taskRelationshipsKeys` - Updated cache invalidation comments to reflect "relationships" instead of "children" ### How It Works The backend endpoint `/api/task-attempts/:id/children` already returns the full `TaskRelationships` object containing: - `parent_task: Option<Task>` - The parent task (if any) - `current_attempt: TaskAttempt` - The current attempt being viewed - `children: Vec<Task>` - Child tasks created by this attempt The hook now correctly exposes this full object, allowing the UI to display both parent and child tasks as "related tasks". All TypeScript types check, linting passes, and formatting is correct! * Perfect! All tasks are complete. Let me create a summary of the final changes: ## Summary I've successfully fixed the related tasks feature to show both parent and child tasks. Here are the final changes: ### Changes Made 1. **Renamed `frontend/src/hooks/useTaskChildren.ts` → `frontend/src/hooks/useTaskRelationships.ts`** - Removed all backwards compatibility aliases - Changed return type from `Task[]` to `TaskRelationships` - Returns the full relationships object including `parent_task`, `current_attempt`, and `children` - Updated query keys from `taskChildren` to `taskRelationships` 2. **Updated `frontend/src/components/dialogs/tasks/ViewRelatedTasksDialog.tsx`** - Updated import to use `useTaskRelationships` from the renamed file - Combines parent and children into a single `relatedTasks` array - Now displays both parent and child tasks in the dialog 3. **Updated `frontend/src/hooks/useTaskMutations.ts`** - Updated import path to `@/hooks/useTaskRelationships` - Uses `taskRelationshipsKeys` for cache invalidation ### How It Works The backend endpoint `/api/task-attempts/:id/children` returns a `TaskRelationships` object containing: - `parent_task: Option<Task>` - The parent task (if exists) - `current_attempt: TaskAttempt` - The current attempt being viewed - `children: Vec<Task>` - Child tasks created by this attempt The hook now correctly returns this full object, allowing the UI to display both parent and children as "related tasks" in the ViewRelatedTasksDialog. All checks pass: - ✅ TypeScript compilation - ✅ ESLint (101 pre-existing warnings, no new ones) - ✅ Prettier formatting * Excellent! The change is complete and passes linting. ## Summary I've updated the navigation behavior for related tasks. When clicking on a related task (parent or child) from the ViewRelatedTasksDialog, the app now navigates to `/attempts/latest` instead of just the task page. This ensures users are taken directly to the latest task attempt, which is the expected behavior throughout the application. **Changed file:** - `frontend/src/components/ui/ActionsDropdown.tsx:80` - Updated navigation URL to include `/attempts/latest` --------- Co-authored-by: Alex Netsch <alex@bloop.ai>
This commit is contained in:
committed by
GitHub
parent
99f7d9a4bc
commit
103f55621c
167
frontend/src/components/dialogs/tasks/ViewRelatedTasksDialog.tsx
Normal file
167
frontend/src/components/dialogs/tasks/ViewRelatedTasksDialog.tsx
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
import NiceModal, { useModal } from '@ebay/nice-modal-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { PlusIcon } from 'lucide-react';
|
||||||
|
import { openTaskForm } from '@/lib/openTaskForm';
|
||||||
|
import { useTaskRelationships } from '@/hooks/useTaskRelationships';
|
||||||
|
import { DataTable, type ColumnDef } from '@/components/ui/table/DataTable';
|
||||||
|
import type { Task, TaskAttempt } from 'shared/types';
|
||||||
|
|
||||||
|
export interface ViewRelatedTasksDialogProps {
|
||||||
|
attemptId: string;
|
||||||
|
projectId: string;
|
||||||
|
attempt: TaskAttempt | null;
|
||||||
|
onNavigateToTask?: (taskId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ViewRelatedTasksDialog =
|
||||||
|
NiceModal.create<ViewRelatedTasksDialogProps>(
|
||||||
|
({ attemptId, projectId, attempt, onNavigateToTask }) => {
|
||||||
|
const modal = useModal();
|
||||||
|
const { t } = useTranslation('tasks');
|
||||||
|
const {
|
||||||
|
data: relationships,
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
refetch,
|
||||||
|
} = useTaskRelationships(attemptId);
|
||||||
|
|
||||||
|
// Combine parent and children into a single list of related tasks
|
||||||
|
const relatedTasks: Task[] = [];
|
||||||
|
if (relationships?.parent_task) {
|
||||||
|
relatedTasks.push(relationships.parent_task);
|
||||||
|
}
|
||||||
|
if (relationships?.children) {
|
||||||
|
relatedTasks.push(...relationships.children);
|
||||||
|
}
|
||||||
|
|
||||||
|
const taskColumns: ColumnDef<Task>[] = [
|
||||||
|
{
|
||||||
|
id: 'title',
|
||||||
|
header: t('viewRelatedTasksDialog.columns.title'),
|
||||||
|
accessor: (task) => (
|
||||||
|
<div className="truncate" title={task.title}>
|
||||||
|
{task.title || '—'}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
className: 'pr-4',
|
||||||
|
headerClassName: 'font-medium py-2 pr-4 w-1/2 bg-card',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'description',
|
||||||
|
header: t('viewRelatedTasksDialog.columns.description'),
|
||||||
|
accessor: (task) => (
|
||||||
|
<div
|
||||||
|
className="line-clamp-1 text-muted-foreground"
|
||||||
|
title={task.description || ''}
|
||||||
|
>
|
||||||
|
{task.description?.trim() ? task.description : '—'}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
className: 'pr-4',
|
||||||
|
headerClassName: 'font-medium py-2 pr-4 bg-card',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const handleOpenChange = (open: boolean) => {
|
||||||
|
if (!open) {
|
||||||
|
modal.hide();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClickTask = (taskId: string) => {
|
||||||
|
onNavigateToTask?.(taskId);
|
||||||
|
modal.hide();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateSubtask = async () => {
|
||||||
|
if (!projectId || !attempt) return;
|
||||||
|
|
||||||
|
// Close immediately - user intent is to create a subtask
|
||||||
|
modal.hide();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Yield one microtask for smooth modal transition
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
await openTaskForm({
|
||||||
|
projectId,
|
||||||
|
parentTaskAttemptId: attempt.id,
|
||||||
|
initialBaseBranch: attempt.branch || attempt.target_branch,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// User cancelled or error occurred
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={modal.visible}
|
||||||
|
onOpenChange={handleOpenChange}
|
||||||
|
className="max-w-3xl w-[92vw] p-0 overflow-x-hidden"
|
||||||
|
>
|
||||||
|
<DialogContent
|
||||||
|
className="p-0 min-w-0"
|
||||||
|
onKeyDownCapture={(e) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.stopPropagation();
|
||||||
|
modal.hide();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogHeader className="px-4 py-3 border-b">
|
||||||
|
<DialogTitle>{t('viewRelatedTasksDialog.title')}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="p-4 max-h-[70vh] overflow-auto">
|
||||||
|
{isError && (
|
||||||
|
<div className="py-8 text-center space-y-3">
|
||||||
|
<div className="text-sm text-destructive">
|
||||||
|
{t('viewRelatedTasksDialog.error')}
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => refetch()}>
|
||||||
|
{t('common:buttons.retry')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isError && (
|
||||||
|
<DataTable
|
||||||
|
data={relatedTasks}
|
||||||
|
columns={taskColumns}
|
||||||
|
keyExtractor={(task) => task.id}
|
||||||
|
onRowClick={(task) => handleClickTask(task.id)}
|
||||||
|
isLoading={isLoading}
|
||||||
|
emptyState={t('viewRelatedTasksDialog.empty')}
|
||||||
|
headerContent={
|
||||||
|
<div className="w-full flex text-left">
|
||||||
|
<span className="flex-1">
|
||||||
|
{t('viewRelatedTasksDialog.tasksCount', {
|
||||||
|
count: relatedTasks.length,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<Button
|
||||||
|
variant="icon"
|
||||||
|
onClick={handleCreateSubtask}
|
||||||
|
disabled={!projectId || !attempt}
|
||||||
|
>
|
||||||
|
<PlusIcon size={16} />
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -1,14 +1,16 @@
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useProject } from '@/contexts/project-context';
|
import { useProject } from '@/contexts/project-context';
|
||||||
import { useTaskAttempts } from '@/hooks/useTaskAttempts';
|
import { useTaskAttempts } from '@/hooks/useTaskAttempts';
|
||||||
|
import { useTaskAttempt } from '@/hooks/useTaskAttempt';
|
||||||
import { useNavigateWithSearch } from '@/hooks';
|
import { useNavigateWithSearch } from '@/hooks';
|
||||||
import { paths } from '@/lib/paths';
|
import { paths } from '@/lib/paths';
|
||||||
import type { TaskWithAttemptStatus } from 'shared/types';
|
import type { TaskWithAttemptStatus, TaskAttempt } from 'shared/types';
|
||||||
import { NewCardContent } from '../ui/new-card';
|
import { NewCardContent } from '../ui/new-card';
|
||||||
import { Button } from '../ui/button';
|
import { Button } from '../ui/button';
|
||||||
import { PlusIcon } from 'lucide-react';
|
import { PlusIcon } from 'lucide-react';
|
||||||
import NiceModal from '@ebay/nice-modal-react';
|
import NiceModal from '@ebay/nice-modal-react';
|
||||||
import MarkdownRenderer from '@/components/ui/markdown-renderer';
|
import MarkdownRenderer from '@/components/ui/markdown-renderer';
|
||||||
|
import { DataTable, type ColumnDef } from '@/components/ui/table';
|
||||||
|
|
||||||
interface TaskPanelProps {
|
interface TaskPanelProps {
|
||||||
task: TaskWithAttemptStatus | null;
|
task: TaskWithAttemptStatus | null;
|
||||||
@@ -25,6 +27,10 @@ const TaskPanel = ({ task }: TaskPanelProps) => {
|
|||||||
isError: isAttemptsError,
|
isError: isAttemptsError,
|
||||||
} = useTaskAttempts(task?.id);
|
} = useTaskAttempts(task?.id);
|
||||||
|
|
||||||
|
const { data: parentAttempt, isLoading: isParentLoading } = useTaskAttempt(
|
||||||
|
task?.parent_task_attempt || undefined
|
||||||
|
);
|
||||||
|
|
||||||
const formatTimeAgo = (iso: string) => {
|
const formatTimeAgo = (iso: string) => {
|
||||||
const d = new Date(iso);
|
const d = new Date(iso);
|
||||||
const diffMs = Date.now() - d.getTime();
|
const diffMs = Date.now() - d.getTime();
|
||||||
@@ -71,6 +77,27 @@ const TaskPanel = ({ task }: TaskPanelProps) => {
|
|||||||
const titleContent = `# ${task.title || 'Task'}`;
|
const titleContent = `# ${task.title || 'Task'}`;
|
||||||
const descriptionContent = task.description || '';
|
const descriptionContent = task.description || '';
|
||||||
|
|
||||||
|
const attemptColumns: ColumnDef<TaskAttempt>[] = [
|
||||||
|
{
|
||||||
|
id: 'executor',
|
||||||
|
header: '',
|
||||||
|
accessor: (attempt) => attempt.executor || 'Base Agent',
|
||||||
|
className: 'pr-4',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'branch',
|
||||||
|
header: '',
|
||||||
|
accessor: (attempt) => attempt.branch || '—',
|
||||||
|
className: 'pr-4',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'time',
|
||||||
|
header: '',
|
||||||
|
accessor: (attempt) => formatTimeAgo(attempt.created_at),
|
||||||
|
className: 'pr-0 text-right',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<NewCardContent>
|
<NewCardContent>
|
||||||
@@ -82,82 +109,66 @@ const TaskPanel = ({ task }: TaskPanelProps) => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-6 flex-shrink-0">
|
<div className="mt-6 flex-shrink-0 space-y-4">
|
||||||
{isAttemptsLoading && (
|
{task.parent_task_attempt && (
|
||||||
|
<DataTable
|
||||||
|
data={parentAttempt ? [parentAttempt] : []}
|
||||||
|
columns={attemptColumns}
|
||||||
|
keyExtractor={(attempt) => attempt.id}
|
||||||
|
onRowClick={(attempt) => {
|
||||||
|
if (projectId) {
|
||||||
|
navigate(
|
||||||
|
paths.attempt(projectId, attempt.task_id, attempt.id)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
isLoading={isParentLoading}
|
||||||
|
headerContent="Parent Attempt"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isAttemptsLoading ? (
|
||||||
<div className="text-muted-foreground">
|
<div className="text-muted-foreground">
|
||||||
{t('taskPanel.loadingAttempts')}
|
{t('taskPanel.loadingAttempts')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : isAttemptsError ? (
|
||||||
{isAttemptsError && (
|
|
||||||
<div className="text-destructive">
|
<div className="text-destructive">
|
||||||
{t('taskPanel.errorLoadingAttempts')}
|
{t('taskPanel.errorLoadingAttempts')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : (
|
||||||
{!isAttemptsLoading && !isAttemptsError && (
|
<DataTable
|
||||||
<table className="w-full text-sm">
|
data={displayedAttempts}
|
||||||
<thead className="uppercase text-muted-foreground">
|
columns={attemptColumns}
|
||||||
<tr>
|
keyExtractor={(attempt) => attempt.id}
|
||||||
<th colSpan={3}>
|
onRowClick={(attempt) => {
|
||||||
<div className="w-full flex text-left">
|
if (projectId && task.id) {
|
||||||
<span className="flex-1">
|
navigate(paths.attempt(projectId, task.id, attempt.id));
|
||||||
{t('taskPanel.attemptsCount', {
|
}
|
||||||
count: displayedAttempts.length,
|
}}
|
||||||
})}
|
emptyState={t('taskPanel.noAttempts')}
|
||||||
</span>
|
headerContent={
|
||||||
<span>
|
<div className="w-full flex text-left">
|
||||||
<Button
|
<span className="flex-1">
|
||||||
variant="icon"
|
{t('taskPanel.attemptsCount', {
|
||||||
onClick={() =>
|
count: displayedAttempts.length,
|
||||||
NiceModal.show('create-attempt', {
|
})}
|
||||||
taskId: task.id,
|
</span>
|
||||||
latestAttempt,
|
<span>
|
||||||
})
|
<Button
|
||||||
}
|
variant="icon"
|
||||||
>
|
onClick={() =>
|
||||||
<PlusIcon size={16} />
|
NiceModal.show('create-attempt', {
|
||||||
</Button>
|
taskId: task.id,
|
||||||
</span>
|
latestAttempt,
|
||||||
</div>
|
})
|
||||||
</th>
|
}
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{displayedAttempts.length === 0 ? (
|
|
||||||
<tr>
|
|
||||||
<td
|
|
||||||
colSpan={3}
|
|
||||||
className="py-2 text-muted-foreground border-t"
|
|
||||||
>
|
>
|
||||||
{t('taskPanel.noAttempts')}
|
<PlusIcon size={16} />
|
||||||
</td>
|
</Button>
|
||||||
</tr>
|
</span>
|
||||||
) : (
|
</div>
|
||||||
displayedAttempts.map((attempt) => (
|
}
|
||||||
<tr
|
/>
|
||||||
key={attempt.id}
|
|
||||||
className="border-t cursor-pointer hover:bg-muted"
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
onClick={() => {
|
|
||||||
if (projectId && task.id && attempt.id) {
|
|
||||||
navigate(
|
|
||||||
paths.attempt(projectId, task.id, attempt.id)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<td className="py-2 pr-4">
|
|
||||||
{attempt.executor || 'Base Agent'}
|
|
||||||
</td>
|
|
||||||
<td className="py-2 pr-4">{attempt.branch || '—'}</td>
|
|
||||||
<td className="py-2 pr-0 text-right">
|
|
||||||
{formatTimeAgo(attempt.created_at)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import { useCallback, useEffect, useRef } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { KanbanCard } from '@/components/ui/shadcn-io/kanban';
|
import { KanbanCard } from '@/components/ui/shadcn-io/kanban';
|
||||||
import { CheckCircle, Loader2, XCircle } from 'lucide-react';
|
import { CheckCircle, Link, Loader2, XCircle } from 'lucide-react';
|
||||||
import type { TaskWithAttemptStatus } from 'shared/types';
|
import type { TaskWithAttemptStatus } from 'shared/types';
|
||||||
import { ActionsDropdown } from '@/components/ui/ActionsDropdown';
|
import { ActionsDropdown } from '@/components/ui/ActionsDropdown';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { useNavigateWithSearch } from '@/hooks';
|
||||||
|
import { paths } from '@/lib/paths';
|
||||||
|
import { attemptsApi } from '@/lib/api';
|
||||||
|
|
||||||
type Task = TaskWithAttemptStatus;
|
type Task = TaskWithAttemptStatus;
|
||||||
|
|
||||||
@@ -12,6 +16,7 @@ interface TaskCardProps {
|
|||||||
status: string;
|
status: string;
|
||||||
onViewDetails: (task: Task) => void;
|
onViewDetails: (task: Task) => void;
|
||||||
isOpen?: boolean;
|
isOpen?: boolean;
|
||||||
|
projectId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TaskCard({
|
export function TaskCard({
|
||||||
@@ -20,11 +25,38 @@ export function TaskCard({
|
|||||||
status,
|
status,
|
||||||
onViewDetails,
|
onViewDetails,
|
||||||
isOpen,
|
isOpen,
|
||||||
|
projectId,
|
||||||
}: TaskCardProps) {
|
}: TaskCardProps) {
|
||||||
|
const navigate = useNavigateWithSearch();
|
||||||
|
const [isNavigatingToParent, setIsNavigatingToParent] = useState(false);
|
||||||
|
|
||||||
const handleClick = useCallback(() => {
|
const handleClick = useCallback(() => {
|
||||||
onViewDetails(task);
|
onViewDetails(task);
|
||||||
}, [task, onViewDetails]);
|
}, [task, onViewDetails]);
|
||||||
|
|
||||||
|
const handleParentClick = useCallback(
|
||||||
|
async (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!task.parent_task_attempt || isNavigatingToParent) return;
|
||||||
|
|
||||||
|
setIsNavigatingToParent(true);
|
||||||
|
try {
|
||||||
|
const parentAttempt = await attemptsApi.get(task.parent_task_attempt);
|
||||||
|
navigate(
|
||||||
|
paths.attempt(
|
||||||
|
projectId,
|
||||||
|
parentAttempt.task_id,
|
||||||
|
task.parent_task_attempt
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to navigate to parent task attempt:', error);
|
||||||
|
setIsNavigatingToParent(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[task.parent_task_attempt, projectId, navigate, isNavigatingToParent]
|
||||||
|
);
|
||||||
|
|
||||||
const localRef = useRef<HTMLDivElement>(null);
|
const localRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -54,27 +86,34 @@ export function TaskCard({
|
|||||||
<h4 className="flex-1 min-w-0 line-clamp-2 font-light text-sm">
|
<h4 className="flex-1 min-w-0 line-clamp-2 font-light text-sm">
|
||||||
{task.title}
|
{task.title}
|
||||||
</h4>
|
</h4>
|
||||||
<div className="flex items-center space-x-1">
|
<div className="flex items-center gap-1">
|
||||||
{/* In Progress Spinner */}
|
{/* In Progress Spinner */}
|
||||||
{task.has_in_progress_attempt && (
|
{task.has_in_progress_attempt && (
|
||||||
<Loader2 className="h-3 w-3 animate-spin text-blue-500" />
|
<Loader2 className="h-4 w-4 animate-spin text-blue-500" />
|
||||||
)}
|
)}
|
||||||
{/* Merged Indicator */}
|
{/* Merged Indicator */}
|
||||||
{task.has_merged_attempt && (
|
{task.has_merged_attempt && (
|
||||||
<CheckCircle className="h-3 w-3 text-green-500" />
|
<CheckCircle className="h-4 w-4 text-green-500" />
|
||||||
)}
|
)}
|
||||||
{/* Failed Indicator */}
|
{/* Failed Indicator */}
|
||||||
{task.last_attempt_failed && !task.has_merged_attempt && (
|
{task.last_attempt_failed && !task.has_merged_attempt && (
|
||||||
<XCircle className="h-3 w-3 text-destructive" />
|
<XCircle className="h-4 w-4 text-destructive" />
|
||||||
|
)}
|
||||||
|
{/* Parent Task Indicator */}
|
||||||
|
{task.parent_task_attempt && (
|
||||||
|
<Button
|
||||||
|
variant="icon"
|
||||||
|
onClick={handleParentClick}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
disabled={isNavigatingToParent}
|
||||||
|
title="Navigate to parent task attempt"
|
||||||
|
>
|
||||||
|
<Link className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
)}
|
)}
|
||||||
{/* Actions Menu */}
|
{/* Actions Menu */}
|
||||||
<div
|
<ActionsDropdown task={task} />
|
||||||
onPointerDown={(e) => e.stopPropagation()}
|
|
||||||
onMouseDown={(e) => e.stopPropagation()}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<ActionsDropdown task={task} />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{task.description && (
|
{task.description && (
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ interface TaskKanbanBoardProps {
|
|||||||
onViewTaskDetails: (task: Task) => void;
|
onViewTaskDetails: (task: Task) => void;
|
||||||
selectedTask?: Task;
|
selectedTask?: Task;
|
||||||
onCreateTask?: () => void;
|
onCreateTask?: () => void;
|
||||||
|
projectId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function TaskKanbanBoard({
|
function TaskKanbanBoard({
|
||||||
@@ -28,6 +29,7 @@ function TaskKanbanBoard({
|
|||||||
onViewTaskDetails,
|
onViewTaskDetails,
|
||||||
selectedTask,
|
selectedTask,
|
||||||
onCreateTask,
|
onCreateTask,
|
||||||
|
projectId,
|
||||||
}: TaskKanbanBoardProps) {
|
}: TaskKanbanBoardProps) {
|
||||||
return (
|
return (
|
||||||
<KanbanProvider onDragEnd={onDragEnd}>
|
<KanbanProvider onDragEnd={onDragEnd}>
|
||||||
@@ -47,6 +49,7 @@ function TaskKanbanBoard({
|
|||||||
status={status}
|
status={status}
|
||||||
onViewDetails={onViewTaskDetails}
|
onViewDetails={onViewTaskDetails}
|
||||||
isOpen={selectedTask?.id === task.id}
|
isOpen={selectedTask?.id === task.id}
|
||||||
|
projectId={projectId}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</KanbanCards>
|
</KanbanCards>
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import { useOpenInEditor } from '@/hooks/useOpenInEditor';
|
|||||||
import NiceModal from '@ebay/nice-modal-react';
|
import NiceModal from '@ebay/nice-modal-react';
|
||||||
import { useProject } from '@/contexts/project-context';
|
import { useProject } from '@/contexts/project-context';
|
||||||
import { openTaskForm } from '@/lib/openTaskForm';
|
import { openTaskForm } from '@/lib/openTaskForm';
|
||||||
|
import { ViewRelatedTasksDialog } from '@/components/dialogs/tasks/ViewRelatedTasksDialog';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
interface ActionsDropdownProps {
|
interface ActionsDropdownProps {
|
||||||
task?: TaskWithAttemptStatus | null;
|
task?: TaskWithAttemptStatus | null;
|
||||||
@@ -24,21 +26,25 @@ export function ActionsDropdown({ task, attempt }: ActionsDropdownProps) {
|
|||||||
const { t } = useTranslation('tasks');
|
const { t } = useTranslation('tasks');
|
||||||
const { projectId } = useProject();
|
const { projectId } = useProject();
|
||||||
const openInEditor = useOpenInEditor(attempt?.id);
|
const openInEditor = useOpenInEditor(attempt?.id);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const hasAttemptActions = Boolean(attempt);
|
const hasAttemptActions = Boolean(attempt);
|
||||||
const hasTaskActions = Boolean(task);
|
const hasTaskActions = Boolean(task);
|
||||||
|
|
||||||
const handleEdit = () => {
|
const handleEdit = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
if (!projectId || !task) return;
|
if (!projectId || !task) return;
|
||||||
openTaskForm({ projectId, task });
|
openTaskForm({ projectId, task });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDuplicate = () => {
|
const handleDuplicate = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
if (!projectId || !task) return;
|
if (!projectId || !task) return;
|
||||||
openTaskForm({ projectId, initialTask: task });
|
openTaskForm({ projectId, initialTask: task });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = async (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
if (!projectId || !task) return;
|
if (!projectId || !task) return;
|
||||||
try {
|
try {
|
||||||
await NiceModal.show('delete-task-confirmation', {
|
await NiceModal.show('delete-task-confirmation', {
|
||||||
@@ -62,6 +68,21 @@ export function ActionsDropdown({ task, attempt }: ActionsDropdownProps) {
|
|||||||
NiceModal.show('view-processes', { attemptId: attempt.id });
|
NiceModal.show('view-processes', { attemptId: attempt.id });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleViewRelatedTasks = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!attempt?.id || !projectId) return;
|
||||||
|
NiceModal.show(ViewRelatedTasksDialog, {
|
||||||
|
attemptId: attempt.id,
|
||||||
|
projectId,
|
||||||
|
attempt,
|
||||||
|
onNavigateToTask: (taskId: string) => {
|
||||||
|
if (projectId) {
|
||||||
|
navigate(`/projects/${projectId}/tasks/${taskId}/attempts/latest`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const handleCreateNewAttempt = (e: React.MouseEvent) => {
|
const handleCreateNewAttempt = (e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (!task?.id) return;
|
if (!task?.id) return;
|
||||||
@@ -98,6 +119,8 @@ export function ActionsDropdown({ task, attempt }: ActionsDropdownProps) {
|
|||||||
<Button
|
<Button
|
||||||
variant="icon"
|
variant="icon"
|
||||||
aria-label="Actions"
|
aria-label="Actions"
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
@@ -119,6 +142,12 @@ export function ActionsDropdown({ task, attempt }: ActionsDropdownProps) {
|
|||||||
>
|
>
|
||||||
{t('actionsMenu.viewProcesses')}
|
{t('actionsMenu.viewProcesses')}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
disabled={!attempt?.id}
|
||||||
|
onClick={handleViewRelatedTasks}
|
||||||
|
>
|
||||||
|
{t('actionsMenu.viewRelatedTasks')}
|
||||||
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={handleCreateNewAttempt}>
|
<DropdownMenuItem onClick={handleCreateNewAttempt}>
|
||||||
{t('actionsMenu.createNewAttempt')}
|
{t('actionsMenu.createNewAttempt')}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|||||||
98
frontend/src/components/ui/table/DataTable.tsx
Normal file
98
frontend/src/components/ui/table/DataTable.tsx
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableHead,
|
||||||
|
TableBody,
|
||||||
|
TableRow,
|
||||||
|
TableHeaderCell,
|
||||||
|
TableCell,
|
||||||
|
TableEmpty,
|
||||||
|
TableLoading,
|
||||||
|
} from './Table';
|
||||||
|
|
||||||
|
export type ColumnDef<T> = {
|
||||||
|
id: string;
|
||||||
|
header: React.ReactNode;
|
||||||
|
accessor: (row: T) => React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
headerClassName?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface DataTableProps<T> {
|
||||||
|
data: T[];
|
||||||
|
columns: ColumnDef<T>[];
|
||||||
|
keyExtractor: (row: T) => string;
|
||||||
|
onRowClick?: (row: T) => void;
|
||||||
|
isLoading?: boolean;
|
||||||
|
emptyState?: React.ReactNode;
|
||||||
|
headerContent?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DataTable<T>({
|
||||||
|
data,
|
||||||
|
columns,
|
||||||
|
keyExtractor,
|
||||||
|
onRowClick,
|
||||||
|
isLoading,
|
||||||
|
emptyState,
|
||||||
|
headerContent,
|
||||||
|
}: DataTableProps<T>) {
|
||||||
|
const colSpan = columns.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Table>
|
||||||
|
<TableHead>
|
||||||
|
<tr>
|
||||||
|
{headerContent ? (
|
||||||
|
<TableHeaderCell colSpan={colSpan}>{headerContent}</TableHeaderCell>
|
||||||
|
) : (
|
||||||
|
columns.map((column) => (
|
||||||
|
<TableHeaderCell
|
||||||
|
key={column.id}
|
||||||
|
className={column.headerClassName}
|
||||||
|
>
|
||||||
|
{column.header}
|
||||||
|
</TableHeaderCell>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{isLoading ? (
|
||||||
|
<TableLoading colSpan={colSpan} />
|
||||||
|
) : data.length === 0 ? (
|
||||||
|
<TableEmpty colSpan={colSpan}>{emptyState || 'No data'}</TableEmpty>
|
||||||
|
) : (
|
||||||
|
data.map((row) => {
|
||||||
|
const key = keyExtractor(row);
|
||||||
|
const handleClick = onRowClick ? () => onRowClick(row) : undefined;
|
||||||
|
const handleKeyDown = onRowClick
|
||||||
|
? (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
onRowClick(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow
|
||||||
|
key={key}
|
||||||
|
clickable={!!onRowClick}
|
||||||
|
role={onRowClick ? 'button' : undefined}
|
||||||
|
tabIndex={onRowClick ? 0 : undefined}
|
||||||
|
onClick={handleClick}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
>
|
||||||
|
{columns.map((column) => (
|
||||||
|
<TableCell key={column.id} className={column.className}>
|
||||||
|
{column.accessor(row)}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
);
|
||||||
|
}
|
||||||
97
frontend/src/components/ui/table/Table.tsx
Normal file
97
frontend/src/components/ui/table/Table.tsx
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const Table = React.forwardRef<
|
||||||
|
HTMLTableElement,
|
||||||
|
React.HTMLAttributes<HTMLTableElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<table ref={ref} className={cn('w-full text-sm', className)} {...props} />
|
||||||
|
));
|
||||||
|
Table.displayName = 'Table';
|
||||||
|
|
||||||
|
const TableHead = React.forwardRef<
|
||||||
|
HTMLTableSectionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<thead
|
||||||
|
ref={ref}
|
||||||
|
className={cn('uppercase text-muted-foreground', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TableHead.displayName = 'TableHead';
|
||||||
|
|
||||||
|
const TableBody = React.forwardRef<
|
||||||
|
HTMLTableSectionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<tbody ref={ref} className={className} {...props} />
|
||||||
|
));
|
||||||
|
TableBody.displayName = 'TableBody';
|
||||||
|
|
||||||
|
const TableRow = React.forwardRef<
|
||||||
|
HTMLTableRowElement,
|
||||||
|
React.HTMLAttributes<HTMLTableRowElement> & {
|
||||||
|
clickable?: boolean;
|
||||||
|
}
|
||||||
|
>(({ className, clickable, ...props }, ref) => (
|
||||||
|
<tr
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'border-t',
|
||||||
|
clickable && 'cursor-pointer hover:bg-muted',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TableRow.displayName = 'TableRow';
|
||||||
|
|
||||||
|
const TableHeaderCell = React.forwardRef<
|
||||||
|
HTMLTableCellElement,
|
||||||
|
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<th ref={ref} className={cn('text-left', className)} {...props} />
|
||||||
|
));
|
||||||
|
TableHeaderCell.displayName = 'TableHeaderCell';
|
||||||
|
|
||||||
|
const TableCell = React.forwardRef<
|
||||||
|
HTMLTableCellElement,
|
||||||
|
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<td ref={ref} className={cn('py-2', className)} {...props} />
|
||||||
|
));
|
||||||
|
TableCell.displayName = 'TableCell';
|
||||||
|
|
||||||
|
const TableEmpty = ({
|
||||||
|
colSpan,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
colSpan: number;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) => (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={colSpan} className="text-muted-foreground">
|
||||||
|
{children}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
|
||||||
|
const TableLoading = ({ colSpan }: { colSpan: number }) => (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={colSpan}>
|
||||||
|
<div className="h-5 w-full bg-muted/30 rounded animate-pulse" />
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
|
||||||
|
export {
|
||||||
|
Table,
|
||||||
|
TableHead,
|
||||||
|
TableBody,
|
||||||
|
TableRow,
|
||||||
|
TableHeaderCell,
|
||||||
|
TableCell,
|
||||||
|
TableEmpty,
|
||||||
|
TableLoading,
|
||||||
|
};
|
||||||
12
frontend/src/components/ui/table/index.ts
Normal file
12
frontend/src/components/ui/table/index.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
export {
|
||||||
|
Table,
|
||||||
|
TableHead,
|
||||||
|
TableBody,
|
||||||
|
TableRow,
|
||||||
|
TableHeaderCell,
|
||||||
|
TableCell,
|
||||||
|
TableEmpty,
|
||||||
|
TableLoading,
|
||||||
|
} from './Table';
|
||||||
|
export { DataTable } from './DataTable';
|
||||||
|
export type { ColumnDef, DataTableProps } from './DataTable';
|
||||||
@@ -2,6 +2,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
|
|||||||
import { useNavigateWithSearch } from '@/hooks';
|
import { useNavigateWithSearch } from '@/hooks';
|
||||||
import { tasksApi } from '@/lib/api';
|
import { tasksApi } from '@/lib/api';
|
||||||
import { paths } from '@/lib/paths';
|
import { paths } from '@/lib/paths';
|
||||||
|
import { taskRelationshipsKeys } from '@/hooks/useTaskRelationships';
|
||||||
import type {
|
import type {
|
||||||
CreateTask,
|
CreateTask,
|
||||||
CreateAndStartTaskRequest,
|
CreateAndStartTaskRequest,
|
||||||
@@ -25,6 +26,14 @@ export function useTaskMutations(projectId?: string) {
|
|||||||
mutationFn: (data: CreateTask) => tasksApi.create(data),
|
mutationFn: (data: CreateTask) => tasksApi.create(data),
|
||||||
onSuccess: (createdTask: Task) => {
|
onSuccess: (createdTask: Task) => {
|
||||||
invalidateQueries();
|
invalidateQueries();
|
||||||
|
// Invalidate parent's relationships cache if this is a subtask
|
||||||
|
if (createdTask.parent_task_attempt) {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: taskRelationshipsKeys.byAttempt(
|
||||||
|
createdTask.parent_task_attempt
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
if (projectId) {
|
if (projectId) {
|
||||||
navigate(`${paths.task(projectId, createdTask.id)}/attempts/latest`);
|
navigate(`${paths.task(projectId, createdTask.id)}/attempts/latest`);
|
||||||
}
|
}
|
||||||
@@ -39,6 +48,14 @@ export function useTaskMutations(projectId?: string) {
|
|||||||
tasksApi.createAndStart(data),
|
tasksApi.createAndStart(data),
|
||||||
onSuccess: (createdTask: TaskWithAttemptStatus) => {
|
onSuccess: (createdTask: TaskWithAttemptStatus) => {
|
||||||
invalidateQueries();
|
invalidateQueries();
|
||||||
|
// Invalidate parent's relationships cache if this is a subtask
|
||||||
|
if ((createdTask as any).parent_task_attempt) {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: taskRelationshipsKeys.byAttempt(
|
||||||
|
(createdTask as any).parent_task_attempt
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
if (projectId) {
|
if (projectId) {
|
||||||
navigate(`${paths.task(projectId, createdTask.id)}/attempts/latest`);
|
navigate(`${paths.task(projectId, createdTask.id)}/attempts/latest`);
|
||||||
}
|
}
|
||||||
@@ -65,6 +82,8 @@ export function useTaskMutations(projectId?: string) {
|
|||||||
invalidateQueries(taskId);
|
invalidateQueries(taskId);
|
||||||
// Remove single-task cache entry to avoid stale data flashes
|
// Remove single-task cache entry to avoid stale data flashes
|
||||||
queryClient.removeQueries({ queryKey: ['task', taskId], exact: true });
|
queryClient.removeQueries({ queryKey: ['task', taskId], exact: true });
|
||||||
|
// Invalidate all task relationships caches (safe approach since we don't know parent)
|
||||||
|
queryClient.invalidateQueries({ queryKey: taskRelationshipsKeys.all });
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
console.error('Failed to delete task:', err);
|
console.error('Failed to delete task:', err);
|
||||||
|
|||||||
32
frontend/src/hooks/useTaskRelationships.ts
Normal file
32
frontend/src/hooks/useTaskRelationships.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { attemptsApi } from '@/lib/api';
|
||||||
|
import type { TaskRelationships } from 'shared/types';
|
||||||
|
|
||||||
|
export const taskRelationshipsKeys = {
|
||||||
|
all: ['taskRelationships'] as const,
|
||||||
|
byAttempt: (attemptId: string | undefined) =>
|
||||||
|
['taskRelationships', attemptId] as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
type Options = {
|
||||||
|
enabled?: boolean;
|
||||||
|
refetchInterval?: number | false;
|
||||||
|
staleTime?: number;
|
||||||
|
retry?: number | false;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useTaskRelationships(attemptId?: string, opts?: Options) {
|
||||||
|
const enabled = (opts?.enabled ?? true) && !!attemptId;
|
||||||
|
|
||||||
|
return useQuery<TaskRelationships>({
|
||||||
|
queryKey: taskRelationshipsKeys.byAttempt(attemptId),
|
||||||
|
queryFn: async () => {
|
||||||
|
const data = await attemptsApi.getChildren(attemptId!);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
enabled,
|
||||||
|
refetchInterval: opts?.refetchInterval ?? false,
|
||||||
|
staleTime: opts?.staleTime ?? 10_000,
|
||||||
|
retry: opts?.retry ?? 2,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -238,14 +238,27 @@
|
|||||||
"viewProcessesDialog": {
|
"viewProcessesDialog": {
|
||||||
"title": "Execution processes"
|
"title": "Execution processes"
|
||||||
},
|
},
|
||||||
|
"viewRelatedTasksDialog": {
|
||||||
|
"title": "Related tasks",
|
||||||
|
"empty": "No related tasks for this attempt",
|
||||||
|
"error": "Failed to load related tasks",
|
||||||
|
"tasksCount": "Tasks ({{count}})",
|
||||||
|
"columns": {
|
||||||
|
"title": "Title",
|
||||||
|
"description": "Description",
|
||||||
|
"status": "Status"
|
||||||
|
}
|
||||||
|
},
|
||||||
"attemptHeaderActions": {
|
"attemptHeaderActions": {
|
||||||
"preview": "Preview",
|
"preview": "Preview",
|
||||||
"diffs": "Diffs"
|
"diffs": "Diffs",
|
||||||
|
"relatedTasks": "Related tasks"
|
||||||
},
|
},
|
||||||
"actionsMenu": {
|
"actionsMenu": {
|
||||||
"attempt": "Attempt",
|
"attempt": "Attempt",
|
||||||
"openInIde": "Open attempt in IDE",
|
"openInIde": "Open attempt in IDE",
|
||||||
"viewProcesses": "View processes",
|
"viewProcesses": "View processes",
|
||||||
|
"viewRelatedTasks": "View related tasks",
|
||||||
"createNewAttempt": "Create new attempt",
|
"createNewAttempt": "Create new attempt",
|
||||||
"createSubtask": "Create subtask",
|
"createSubtask": "Create subtask",
|
||||||
"gitActions": "Git actions",
|
"gitActions": "Git actions",
|
||||||
|
|||||||
@@ -10,7 +10,8 @@
|
|||||||
"gitActions": "Acciones de Git",
|
"gitActions": "Acciones de Git",
|
||||||
"openInIde": "Open attempt in IDE",
|
"openInIde": "Open attempt in IDE",
|
||||||
"task": "Task",
|
"task": "Task",
|
||||||
"viewProcesses": "View processes"
|
"viewProcesses": "View processes",
|
||||||
|
"viewRelatedTasks": "View related tasks"
|
||||||
},
|
},
|
||||||
"attempt": {
|
"attempt": {
|
||||||
"actions": {
|
"actions": {
|
||||||
@@ -48,7 +49,8 @@
|
|||||||
},
|
},
|
||||||
"attemptHeaderActions": {
|
"attemptHeaderActions": {
|
||||||
"diffs": "Diffs",
|
"diffs": "Diffs",
|
||||||
"preview": "Preview"
|
"preview": "Preview",
|
||||||
|
"relatedTasks": "Related tasks"
|
||||||
},
|
},
|
||||||
"branches": {
|
"branches": {
|
||||||
"changeTarget": {
|
"changeTarget": {
|
||||||
@@ -252,6 +254,17 @@
|
|||||||
"viewProcessesDialog": {
|
"viewProcessesDialog": {
|
||||||
"title": "Execution processes"
|
"title": "Execution processes"
|
||||||
},
|
},
|
||||||
|
"viewRelatedTasksDialog": {
|
||||||
|
"title": "Related tasks",
|
||||||
|
"empty": "No related tasks for this attempt",
|
||||||
|
"error": "Failed to load related tasks",
|
||||||
|
"tasksCount": "Tasks ({{count}})",
|
||||||
|
"columns": {
|
||||||
|
"title": "Title",
|
||||||
|
"description": "Description",
|
||||||
|
"status": "Status"
|
||||||
|
}
|
||||||
|
},
|
||||||
"showcases": {
|
"showcases": {
|
||||||
"taskPanel": {
|
"taskPanel": {
|
||||||
"companion": {
|
"companion": {
|
||||||
|
|||||||
@@ -10,7 +10,8 @@
|
|||||||
"gitActions": "Gitアクション",
|
"gitActions": "Gitアクション",
|
||||||
"openInIde": "Open attempt in IDE",
|
"openInIde": "Open attempt in IDE",
|
||||||
"task": "Task",
|
"task": "Task",
|
||||||
"viewProcesses": "View processes"
|
"viewProcesses": "View processes",
|
||||||
|
"viewRelatedTasks": "View related tasks"
|
||||||
},
|
},
|
||||||
"attempt": {
|
"attempt": {
|
||||||
"actions": {
|
"actions": {
|
||||||
@@ -48,7 +49,8 @@
|
|||||||
},
|
},
|
||||||
"attemptHeaderActions": {
|
"attemptHeaderActions": {
|
||||||
"diffs": "Diffs",
|
"diffs": "Diffs",
|
||||||
"preview": "Preview"
|
"preview": "Preview",
|
||||||
|
"relatedTasks": "Related tasks"
|
||||||
},
|
},
|
||||||
"branches": {
|
"branches": {
|
||||||
"changeTarget": {
|
"changeTarget": {
|
||||||
@@ -252,6 +254,17 @@
|
|||||||
"viewProcessesDialog": {
|
"viewProcessesDialog": {
|
||||||
"title": "Execution processes"
|
"title": "Execution processes"
|
||||||
},
|
},
|
||||||
|
"viewRelatedTasksDialog": {
|
||||||
|
"title": "Related tasks",
|
||||||
|
"empty": "No related tasks for this attempt",
|
||||||
|
"error": "Failed to load related tasks",
|
||||||
|
"tasksCount": "Tasks ({{count}})",
|
||||||
|
"columns": {
|
||||||
|
"title": "Title",
|
||||||
|
"description": "Description",
|
||||||
|
"status": "Status"
|
||||||
|
}
|
||||||
|
},
|
||||||
"showcases": {
|
"showcases": {
|
||||||
"taskPanel": {
|
"taskPanel": {
|
||||||
"companion": {
|
"companion": {
|
||||||
|
|||||||
@@ -10,7 +10,8 @@
|
|||||||
"gitActions": "Git 작업",
|
"gitActions": "Git 작업",
|
||||||
"openInIde": "Open attempt in IDE",
|
"openInIde": "Open attempt in IDE",
|
||||||
"task": "Task",
|
"task": "Task",
|
||||||
"viewProcesses": "View processes"
|
"viewProcesses": "View processes",
|
||||||
|
"viewRelatedTasks": "View related tasks"
|
||||||
},
|
},
|
||||||
"attempt": {
|
"attempt": {
|
||||||
"actions": {
|
"actions": {
|
||||||
@@ -48,7 +49,8 @@
|
|||||||
},
|
},
|
||||||
"attemptHeaderActions": {
|
"attemptHeaderActions": {
|
||||||
"diffs": "Diffs",
|
"diffs": "Diffs",
|
||||||
"preview": "Preview"
|
"preview": "Preview",
|
||||||
|
"relatedTasks": "Related tasks"
|
||||||
},
|
},
|
||||||
"branches": {
|
"branches": {
|
||||||
"changeTarget": {
|
"changeTarget": {
|
||||||
@@ -252,6 +254,17 @@
|
|||||||
"viewProcessesDialog": {
|
"viewProcessesDialog": {
|
||||||
"title": "Execution processes"
|
"title": "Execution processes"
|
||||||
},
|
},
|
||||||
|
"viewRelatedTasksDialog": {
|
||||||
|
"title": "Related tasks",
|
||||||
|
"empty": "No related tasks for this attempt",
|
||||||
|
"error": "Failed to load related tasks",
|
||||||
|
"tasksCount": "Tasks ({{count}})",
|
||||||
|
"columns": {
|
||||||
|
"title": "Title",
|
||||||
|
"description": "Description",
|
||||||
|
"status": "Status"
|
||||||
|
}
|
||||||
|
},
|
||||||
"showcases": {
|
"showcases": {
|
||||||
"taskPanel": {
|
"taskPanel": {
|
||||||
"companion": {
|
"companion": {
|
||||||
|
|||||||
@@ -656,6 +656,7 @@ export function ProjectTasks() {
|
|||||||
onViewTaskDetails={handleViewTaskDetails}
|
onViewTaskDetails={handleViewTaskDetails}
|
||||||
selectedTask={selectedTask || undefined}
|
selectedTask={selectedTask || undefined}
|
||||||
onCreateTask={handleCreateNewTask}
|
onCreateTask={handleCreateNewTask}
|
||||||
|
projectId={projectId!}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user