Files
vibe-kanban/frontend/src/pages/project-tasks.tsx
Britannio Jarrett c48ba45573 feature showcase (vibe-kanban) (#1042)
* Done! I've created a floating onboarding panel that:

1. **Anchors to the resize handle** - Uses `#handle-kr` (the drag handle between kanban and task panel) as an anchor point
2. **Floats to the left** - Positioned with fixed positioning, offset from the handle's right edge
3. **Tracks panel movement** - Uses MutationObserver and resize listeners to stay anchored even when the panel is resized
4. **Animates in/out** - Smooth fade and slide transitions with framer-motion
5. **Shows for all tasks** - Displays whenever `isPanelOpen` is true (desktop only)

The component includes placeholder content with a welcome message, step indicators (1 of 3), and Next/Skip buttons ready for your multi-stage guide implementation.

* Cleanup script changes for task attempt 5c2c52b0-90de-4f9a-98a2-cade3e471c4f

* Perfect! I've updated the onboarding component with a multi-stage system featuring 5 sections:

**Features:**
- **5 stages**: Follow-ups, Code Review, Git Actions, Live Previews, and VK Companion
- **Video support**: Each stage displays the same video (for now) with autoplay and loop
- **Navigation**: Previous/Next buttons, with "Finish" on the last stage
- **Progress indicators**: Dots and "Step X of 5" counter
- **Smooth transitions**: Content slides between stages using framer-motion
- **Skip option**: Available on all stages

Each stage has a descriptive title and two-sentence description explaining the feature. The videos automatically reload and play when switching between stages.

* Increased the panel width from 384px (w-96) to 576px (w-[36rem]). The video should now be significantly larger.

* Fixed! The positioning now:
- Checks the panel height and viewport bounds
- Keeps 20px margin from top and bottom edges
- Adjusts the anchor position if the panel would overflow
- Re-calculates when stage changes (since content height may vary)

* Cleanup script changes for task attempt 5c2c52b0-90de-4f9a-98a2-cade3e471c4f

* Fixed! Now the panel:
- Checks if it would overflow at top or bottom when centered
- Switches to top-aligned positioning when it would overflow
- Calculates a safe top position that keeps the entire panel visible
- Uses `translateY(0)` instead of `translateY(-50%)` when top-aligned

* Cleanup script changes for task attempt 5c2c52b0-90de-4f9a-98a2-cade3e471c4f

* Fixed! I simplified the positioning logic:
- Centers the panel vertically on the handle position
- No transform needed - uses absolute top positioning
- Ensures 20px margins from viewport edges
- Panel stays fully visible when it would overflow

* Cleanup script changes for task attempt 5c2c52b0-90de-4f9a-98a2-cade3e471c4f

* Fixed! Now it properly centers the panel on the middle of the handle by using `handleRect.top + handleRect.height / 2` as the center point, then offsetting by half the panel height.

* Cleanup script changes for task attempt 5c2c52b0-90de-4f9a-98a2-cade3e471c4f

* Done! Converted to a modal with:
- **2/3 screen width** centered horizontally
- **Bottom aligned** with 2rem margin from bottom
- **Backdrop overlay** that doesn't dismiss on click
- **No ESC dismiss** (removed keyboard handler)
- Smooth slide-up animation from bottom

* Fixed! The issue was that framer-motion's `y` transform was overriding the Tailwind `-translate-x-1/2`. Now using inline style for `translateX(-50%)` which works alongside motion's `y` transform.

* Perfect! Now the modal is horizontally centered using `left-[16.66%]` (which is 100% - 66.66% divided by 2) and `w-2/3`, which means it takes up 2/3 of the width and is centered. This won't conflict with framer-motion's transform animations for the y-axis.

* Done! I've made the following changes:

1. **Removed Skip and Close buttons** - Users can just click "Next" through all stages or "Finish" on the last one
2. **Improved step count visual hierarchy:**
   - Moved the numeric count (1 / 5) to the top right next to the title
   - Made it more prominent with `font-medium` styling
   - Changed the dots to full-width progress bars that stretch across the bottom
   - Progress bars use `h-1` height and `flex-1` to distribute evenly
   - Cleaner, more modern progress indicator

The layout now has better visual hierarchy with the count at the top and a progress bar below the description.

* Cleanup script changes for task attempt 5c2c52b0-90de-4f9a-98a2-cade3e471c4f

* Fixed! I've added `aspect-video` to the video container, which reserves a 16:9 aspect ratio space before the video loads. The video now uses `h-full object-cover` to fill that space. This prevents layout shift during the entrance animation.

* Cleanup script changes for task attempt 5c2c52b0-90de-4f9a-98a2-cade3e471c4f

* Perfect! I've updated the buttons to match the app's theme:

1. **Added icons**: `ChevronLeft` for Previous and `ChevronRight` for Next
2. **Removed border radius**: No `rounded` class
3. **Outline border**: Using `border border-input` for Previous and `border border-foreground` for Next
4. **Consistent sizing**: Using `h-10 px-4 py-2` matching the default button size
5. **Proper styling**: Following the button variants from the app's button component

The buttons now match the visual style used throughout the rest of the application.

* Cleanup script changes for task attempt 5c2c52b0-90de-4f9a-98a2-cade3e471c4f

* ## Summary

I've successfully implemented a **generic feature showcase system** following delegation-oriented development:

###  Completed Components

1. **Type System** - `frontend/src/types/showcase.ts`
2. **Configuration** - `frontend/src/config/showcases.ts` (task panel showcase with 5 stages)
3. **Persistence** - `frontend/src/utils/showcasePersistence.ts` (localStorage-based)
4. **Video Progress Hook** - `frontend/src/hooks/useVideoProgress.ts` (tracks loading/buffering/playback)
5. **Media Component** - `frontend/src/components/showcase/ShowcaseStageMedia.tsx` (images/videos with loading states)
6. **Main Modal** - `frontend/src/components/showcase/FeatureShowcaseModal.tsx` (with ESC key handling)
7. **i18n Keys** - Added to `tasks.json`
8. **Integration** - Replaced TaskPanelOnboarding in project-tasks.tsx

### 🎯 Key Features

-  Supports images and videos with loading indicators
-  Video progress bar (buffered + played, like vibe-kanban-website)
-  ESC key properly scoped (closes modal, not task panel)
-  LocalStorage persistence (`showcase:<id>:v<version>:seen`)
-  i18n support for all content
-  Single-stage support (hides nav if 1 stage)
-  Flexible trigger location (configured per showcase)
-  All TypeScript and Rust checks pass

### 🧪 Ready for Browser Testing

What port is your dev server running on? I'll test:
1. Opening a task triggers the showcase
2. Video loading states and progress bars
3. Stage navigation (Previous/Next/Finish)
4. ESC closes only modal, not task panel
5. Persistence (doesn't show again after Finish)

* Cleanup script changes for task attempt 5c2c52b0-90de-4f9a-98a2-cade3e471c4f

* ##  Feature Showcase System - Complete!

Successfully implemented a **generic, reusable feature showcase system** following delegation-oriented development with full QA verification.

### 📦 Deliverables

**Core Components:**
- `FeatureShowcaseModal` - Main modal with ESC key scope management
- `ShowcaseStageMedia` - Image/video renderer with loading states & progress bars
- `useVideoProgress` - Hook for video buffering/playback tracking
- `showcasePersistence` - LocalStorage helpers for seen tracking
- Type definitions & configuration system

**Integration:**
- Migrated task panel onboarding to new system
- Added i18n keys for all showcase content
- Removed old `TaskPanelOnboarding` component

###  Features

 **Flexible Triggers** - Can be placed anywhere (currently on task panel open)
 **ESC Key Handling** - Proper scope management (closes modal, not underlying feature)
 **Media Support** - Images and videos with loading indicators
 **Video Progress** - Thin progress bar showing buffered/played state
 **Persistence** - LocalStorage tracking with version support
 **i18n Ready** - Full localization support
 **Single/Multi-stage** - Adapts UI for 1 or multiple stages
 **Mobile-aware** - Only shows on desktop (width ≥ 1280px)

### 🧪 QA Results

All tests passed including:
- Showcase triggering on task open
- Navigation between 5 stages
- ESC key scope handling
- Persistence across sessions
- Responsive behavior
- Edge case handling

### 🎯 Usage

To add a new showcase:
1. Define config in `showcases.ts`
2. Add i18n keys in `tasks.json`
3. Trigger where appropriate with `hasSeen()` check
4. Render `<FeatureShowcaseModal>` with config

* Done! Removed backdrop click to close functionality. Users can only close the showcase by:
- Clicking "Finish" button on the last stage
- Pressing ESC key

* Fixed! The showcase now:
- Shows on **all screen sizes** (mobile and desktop)
- **Mobile**: Full width with 1rem horizontal padding
- **Desktop (xl+)**: 2/3 width, centered
- Bottom spacing adjusts responsively (1rem mobile, 2rem desktop)

* update task text

* Done! ESC is now captured but does nothing. Users must click through the stages or use "Finish" to close the showcase.

* update task text

* i18n (vibe-kanban 7f62c07e)

run scripts/check-i18n.sh and resolve any discrepancies by translating missing values.

* Done! All changes complete:

 **Mobile support** - Removed mobile check, now shows on all devices with responsive width
 **Component docs** - Added JSDoc to both FeatureShowcaseModal and ShowcaseStageMedia
 **Progress bar** - Increased height from 2px to 4px for better visibility
 **Developer guide** - Created comprehensive README.md in `components/showcase/`

The showcase now works across all screen sizes and has full documentation for future development.

* Cleanup script changes for task attempt 5c2c52b0-90de-4f9a-98a2-cade3e471c4f

* Fixed! Removed the incorrect ESC test from the checklist. The showcase can only be closed by clicking "Finish" on the last stage (ESC is captured but disabled).

* Fixed! Removed that line and also corrected "after closing" to "after finishing" to be accurate.

* Fixed! Now all button labels use i18n keys:
- `showcases.buttons.previous`
- `showcases.buttons.next`
- `showcases.buttons.finish`

* Perfect! All review comments addressed:

##  Changes Complete

1. **Scope handling documentation** - Added detailed comment explaining why we enable/disable scopes
2. **Created `useShowcaseTrigger` hook** - Extracted and simplified the trigger logic
3. **Refactored project-tasks.tsx** - Now uses the hook (much cleaner)
4. **Updated README** - Reflects the new hook-based usage pattern

The code is now more robust and maintainable. All TypeScript and Rust checks pass. The showcase system is ready to be easily reused across any feature in the app.

* Cleanup script changes for task attempt 5c2c52b0-90de-4f9a-98a2-cade3e471c4f

* video showcase bug (vibe-kanban 383590da)

CHANGED FILES:
frontend/src/components/showcase/FeatureShowcaseModal.tsx
frontend/src/components/showcase/README.md
frontend/src/components/showcase/ShowcaseStageMedia.tsx
frontend/src/config/showcases.ts
frontend/src/hooks/useShowcaseTrigger.ts
frontend/src/hooks/useVideoProgress.ts
frontend/src/i18n/locales/en/tasks.json
frontend/src/i18n/locales/es/tasks.json
frontend/src/i18n/locales/ja/tasks.json
frontend/src/i18n/locales/ko/tasks.json
frontend/src/pages/project-tasks.tsx
frontend/src/types/showcase.ts
frontend/src/utils/showcasePersistence.ts

The showcase is sometimes being shown more than once. Users press the finish button, the showcase closes, then they click on another task and it opens again.

Use the oracle to extensively diagnose this issue then resolve it.

* showcase improvement 2 (vibe-kanban 3e5a7c01)

frontend/src/components/showcase/FeatureShowcaseModal.tsx
frontend/src/components/showcase/README.md
frontend/src/components/showcase/ShowcaseStageMedia.tsx
frontend/src/config/showcases.ts
frontend/src/hooks/useShowcaseTrigger.ts
frontend/src/hooks/useVideoProgress.ts
frontend/src/i18n/locales/en/tasks.json
frontend/src/i18n/locales/es/tasks.json
frontend/src/i18n/locales/ja/tasks.json
frontend/src/i18n/locales/ko/tasks.json
frontend/src/pages/project-tasks.tsx
frontend/src/types/showcase.ts
frontend/src/utils/showcasePersistence.ts

Do we need semantic versioning? We can just use a single number right (1, 2, ...). We aren't ever parsing the sem var and doing comparisons right? Or am i mistaken?

* showcase improvement 1 (vibe-kanban be7d377b)

see the first useEffect in FeatureShowcaseModal
Isn't this too specific to the scopes featured in the task panel? If we want to show this showcase elsewhere, it might change.

* remove unused clearSeen() function

* feature showcase | no auto replay (vibe-kanban 2e945b1f)

frontend/src/components/showcase/FeatureShowcaseModal.tsx
frontend/src/components/showcase/ShowcaseStageMedia.tsx
frontend/src/config/showcases.ts
frontend/src/hooks/useShowcaseTrigger.ts
frontend/src/hooks/useVideoProgress.ts
frontend/src/pages/project-tasks.tsx
frontend/src/types/showcase.ts
frontend/src/utils/showcasePersistence.ts

Each video should only play once, then once finished, a replay button should appear in the center.

* updated videos

* feature showcase | prevent cut off (vibe-kanban 79364905)

frontend/src/components/showcase/FeatureShowcaseModal.tsx
frontend/src/components/showcase/ShowcaseStageMedia.tsx
frontend/src/config/showcases.ts
frontend/src/hooks/useShowcaseTrigger.ts
frontend/src/pages/project-tasks.tsx
frontend/src/types/showcase.ts
frontend/src/utils/showcasePersistence.ts

On some displays, the showcase pop up height is too large. Use the oracle to hypothesize various solutions to this then implement the most promising one.

* update showcase videos

* remove showcase readme
2025-10-17 15:46:21 +01:00

772 lines
22 KiB
TypeScript

import { useCallback, useEffect, useMemo, useState } from 'react';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { AlertTriangle, Plus } from 'lucide-react';
import { Loader } from '@/components/ui/loader';
import { tasksApi } from '@/lib/api';
import type { GitBranch } from 'shared/types';
import { openTaskForm } from '@/lib/openTaskForm';
import { FeatureShowcaseModal } from '@/components/showcase/FeatureShowcaseModal';
import { taskPanelShowcase } from '@/config/showcases';
import { useShowcaseTrigger } from '@/hooks/useShowcaseTrigger';
import { useSearch } from '@/contexts/search-context';
import { useProject } from '@/contexts/project-context';
import { useTaskAttempts } from '@/hooks/useTaskAttempts';
import { useTaskAttempt } from '@/hooks/useTaskAttempt';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { useBranchStatus, useAttemptExecution } from '@/hooks';
import { projectsApi } from '@/lib/api';
import { paths } from '@/lib/paths';
import { ExecutionProcessesProvider } from '@/contexts/ExecutionProcessesContext';
import { ClickedElementsProvider } from '@/contexts/ClickedElementsProvider';
import { ReviewProvider } from '@/contexts/ReviewProvider';
import {
useKeyCreate,
useKeyExit,
useKeyFocusSearch,
useKeyNavUp,
useKeyNavDown,
useKeyNavLeft,
useKeyNavRight,
useKeyOpenDetails,
Scope,
useKeyDeleteTask,
useKeyCycleViewBackward,
} from '@/keyboard';
import TaskKanbanBoard from '@/components/tasks/TaskKanbanBoard';
import type { TaskWithAttemptStatus } from 'shared/types';
import type { DragEndEvent } from '@/components/ui/shadcn-io/kanban';
import { useProjectTasks } from '@/hooks/useProjectTasks';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { useHotkeysContext } from 'react-hotkeys-hook';
import { TasksLayout, type LayoutMode } from '@/components/layout/TasksLayout';
import { PreviewPanel } from '@/components/panels/PreviewPanel';
import { DiffsPanel } from '@/components/panels/DiffsPanel';
import TaskAttemptPanel from '@/components/panels/TaskAttemptPanel';
import TaskPanel from '@/components/panels/TaskPanel';
import TodoPanel from '@/components/tasks/TodoPanel';
import { NewCard, NewCardHeader } from '@/components/ui/new-card';
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbList,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
} from '@/components/ui/breadcrumb';
import { AttemptHeaderActions } from '@/components/panels/AttemptHeaderActions';
import { TaskPanelHeaderActions } from '@/components/panels/TaskPanelHeaderActions';
type Task = TaskWithAttemptStatus;
const TASK_STATUSES = [
'todo',
'inprogress',
'inreview',
'done',
'cancelled',
] as const;
function DiffsPanelContainer({
attempt,
selectedTask,
projectId,
branchStatus,
branches,
setGitError,
}: {
attempt: any;
selectedTask: any;
projectId: string;
branchStatus: any;
branches: GitBranch[];
setGitError: (error: string | null) => void;
}) {
const { isAttemptRunning } = useAttemptExecution(attempt?.id);
return (
<DiffsPanel
selectedAttempt={attempt}
gitOps={
attempt && selectedTask
? {
task: selectedTask,
projectId,
branchStatus: branchStatus ?? null,
branches,
isAttemptRunning,
setError: setGitError,
selectedBranch: branchStatus?.target_branch_name ?? null,
}
: undefined
}
/>
);
}
export function ProjectTasks() {
const { t } = useTranslation(['tasks', 'common']);
const { taskId, attemptId } = useParams<{
projectId: string;
taskId?: string;
attemptId?: string;
}>();
const navigate = useNavigate();
const { enableScope, disableScope } = useHotkeysContext();
const [searchParams, setSearchParams] = useSearchParams();
const isXL = useMediaQuery('(min-width: 1280px)');
const isMobile = !isXL;
const {
projectId,
isLoading: projectLoading,
error: projectError,
} = useProject();
useEffect(() => {
enableScope(Scope.KANBAN);
return () => {
disableScope(Scope.KANBAN);
};
}, [enableScope, disableScope]);
const handleCreateTask = useCallback(() => {
if (projectId) {
openTaskForm({ projectId });
}
}, [projectId]);
const { query: searchQuery, focusInput } = useSearch();
const {
tasks,
tasksById,
isLoading,
error: streamError,
} = useProjectTasks(projectId || '');
const selectedTask = useMemo(
() => (taskId ? (tasksById[taskId] ?? null) : null),
[taskId, tasksById]
);
const isPanelOpen = Boolean(taskId && selectedTask);
const { isOpen: showTaskPanelShowcase, close: closeTaskPanelShowcase } =
useShowcaseTrigger(taskPanelShowcase, {
enabled: isPanelOpen,
});
const isLatest = attemptId === 'latest';
const { data: attempts = [], isLoading: isAttemptsLoading } = useTaskAttempts(
taskId,
{
enabled: !!taskId && isLatest,
}
);
const latestAttemptId = useMemo(() => {
if (!attempts?.length) return undefined;
return [...attempts].sort((a, b) => {
const diff =
new Date(b.created_at).getTime() - new Date(a.created_at).getTime();
if (diff !== 0) return diff;
return a.id.localeCompare(b.id);
})[0].id;
}, [attempts]);
useEffect(() => {
if (!projectId || !taskId) return;
if (!isLatest) return;
if (isAttemptsLoading) return;
if (!latestAttemptId) {
navigateWithSearch(paths.task(projectId, taskId), { replace: true });
return;
}
navigateWithSearch(paths.attempt(projectId, taskId, latestAttemptId), {
replace: true,
});
}, [
projectId,
taskId,
isLatest,
isAttemptsLoading,
latestAttemptId,
navigate,
]);
useEffect(() => {
if (!projectId || !taskId || isLoading) return;
if (selectedTask === null) {
navigate(`/projects/${projectId}/tasks`, { replace: true });
}
}, [projectId, taskId, isLoading, selectedTask, navigate]);
const effectiveAttemptId = attemptId === 'latest' ? undefined : attemptId;
const isTaskView = !!taskId && !effectiveAttemptId;
const { data: attempt } = useTaskAttempt(effectiveAttemptId);
const { data: branchStatus } = useBranchStatus(attempt?.id);
const [branches, setBranches] = useState<GitBranch[]>([]);
const [gitError, setGitError] = useState<string | null>(null);
useEffect(() => {
if (!projectId) return;
projectsApi
.getBranches(projectId)
.then(setBranches)
.catch(() => setBranches([]));
}, [projectId]);
const rawMode = searchParams.get('view') as LayoutMode;
const mode: LayoutMode =
rawMode === 'preview' || rawMode === 'diffs' ? rawMode : null;
// TODO: Remove this redirect after v0.1.0 (legacy URL support for bookmarked links)
// Migrates old `view=logs` to `view=diffs`
useEffect(() => {
const view = searchParams.get('view');
if (view === 'logs') {
const params = new URLSearchParams(searchParams);
params.set('view', 'diffs');
setSearchParams(params, { replace: true });
}
}, [searchParams, setSearchParams]);
const setMode = useCallback(
(newMode: LayoutMode) => {
const params = new URLSearchParams(searchParams);
if (newMode === null) {
params.delete('view');
} else {
params.set('view', newMode);
}
setSearchParams(params, { replace: true });
},
[searchParams, setSearchParams]
);
const navigateWithSearch = useCallback(
(pathname: string, options?: { replace?: boolean }) => {
const search = searchParams.toString();
navigate({ pathname, search: search ? `?${search}` : '' }, options);
},
[navigate, searchParams]
);
const handleCreateNewTask = useCallback(() => {
handleCreateTask();
}, [handleCreateTask]);
useKeyCreate(handleCreateNewTask, {
scope: Scope.KANBAN,
preventDefault: true,
});
useKeyFocusSearch(
() => {
focusInput();
},
{
scope: Scope.KANBAN,
preventDefault: true,
}
);
useKeyExit(
() => {
if (isPanelOpen) {
handleClosePanel();
} else {
navigate('/projects');
}
},
{ scope: Scope.KANBAN }
);
const filteredTasks = useMemo(() => {
if (!searchQuery.trim()) {
return tasks;
}
const query = searchQuery.toLowerCase();
return tasks.filter(
(task) =>
task.title.toLowerCase().includes(query) ||
(task.description && task.description.toLowerCase().includes(query))
);
}, [tasks, searchQuery]);
const groupedFilteredTasks = useMemo(() => {
const groups: Record<string, Task[]> = {};
TASK_STATUSES.forEach((status) => {
groups[status] = [];
});
filteredTasks.forEach((task) => {
const normalizedStatus = task.status.toLowerCase();
if (groups[normalizedStatus]) {
groups[normalizedStatus].push(task);
} else {
groups['todo'].push(task);
}
});
return groups;
}, [filteredTasks]);
useKeyNavUp(
() => {
selectPreviousTask();
},
{
scope: Scope.KANBAN,
preventDefault: true,
}
);
useKeyNavDown(
() => {
selectNextTask();
},
{
scope: Scope.KANBAN,
preventDefault: true,
}
);
useKeyNavLeft(
() => {
selectPreviousColumn();
},
{
scope: Scope.KANBAN,
preventDefault: true,
}
);
useKeyNavRight(
() => {
selectNextColumn();
},
{
scope: Scope.KANBAN,
preventDefault: true,
}
);
/**
* Cycle the attempt area view.
* - When panel is closed: opens task details (if a task is selected)
* - When panel is open: cycles among [attempt, preview, diffs]
*/
const cycleView = useCallback(
(direction: 'forward' | 'backward' = 'forward') => {
const order: LayoutMode[] = [null, 'preview', 'diffs'];
const idx = order.indexOf(mode);
const next =
direction === 'forward'
? order[(idx + 1) % order.length]
: order[(idx - 1 + order.length) % order.length];
setMode(next);
},
[mode, setMode]
);
const cycleViewForward = useCallback(() => cycleView('forward'), [cycleView]);
const cycleViewBackward = useCallback(
() => cycleView('backward'),
[cycleView]
);
// meta/ctrl+enter → open details or cycle forward
useKeyOpenDetails(
() => {
if (isPanelOpen) {
cycleViewForward();
} else if (selectedTask) {
handleViewTaskDetails(selectedTask);
}
},
{ scope: Scope.KANBAN }
);
// meta/ctrl+shift+enter → cycle backward
useKeyCycleViewBackward(
() => {
if (isPanelOpen) {
cycleViewBackward();
}
},
{ scope: Scope.KANBAN, preventDefault: true }
);
useKeyDeleteTask(
() => {
// Note: Delete is now handled by TaskActionsDropdown
// This keyboard shortcut could trigger the dropdown action if needed
},
{
scope: Scope.KANBAN,
preventDefault: true,
}
);
const handleClosePanel = useCallback(() => {
if (projectId) {
navigate(`/projects/${projectId}/tasks`, { replace: true });
}
}, [projectId, navigate]);
const handleViewTaskDetails = useCallback(
(task: Task, attemptIdToShow?: string) => {
if (attemptIdToShow) {
navigateWithSearch(paths.attempt(projectId!, task.id, attemptIdToShow));
} else {
navigateWithSearch(
`${paths.task(projectId!, task.id)}/attempts/latest`
);
}
},
[projectId, navigateWithSearch]
);
const selectNextTask = useCallback(() => {
if (selectedTask) {
const tasksInStatus = groupedFilteredTasks[selectedTask.status] || [];
const currentIndex = tasksInStatus.findIndex(
(task) => task.id === selectedTask.id
);
if (currentIndex >= 0 && currentIndex < tasksInStatus.length - 1) {
handleViewTaskDetails(tasksInStatus[currentIndex + 1]);
}
} else {
for (const status of TASK_STATUSES) {
const tasks = groupedFilteredTasks[status];
if (tasks && tasks.length > 0) {
handleViewTaskDetails(tasks[0]);
break;
}
}
}
}, [selectedTask, groupedFilteredTasks, handleViewTaskDetails]);
const selectPreviousTask = useCallback(() => {
if (selectedTask) {
const tasksInStatus = groupedFilteredTasks[selectedTask.status] || [];
const currentIndex = tasksInStatus.findIndex(
(task) => task.id === selectedTask.id
);
if (currentIndex > 0) {
handleViewTaskDetails(tasksInStatus[currentIndex - 1]);
}
} else {
for (const status of TASK_STATUSES) {
const tasks = groupedFilteredTasks[status];
if (tasks && tasks.length > 0) {
handleViewTaskDetails(tasks[0]);
break;
}
}
}
}, [selectedTask, groupedFilteredTasks, handleViewTaskDetails]);
const selectNextColumn = useCallback(() => {
if (selectedTask) {
const currentIndex = TASK_STATUSES.findIndex(
(status) => status === selectedTask.status
);
for (let i = currentIndex + 1; i < TASK_STATUSES.length; i++) {
const tasks = groupedFilteredTasks[TASK_STATUSES[i]];
if (tasks && tasks.length > 0) {
handleViewTaskDetails(tasks[0]);
return;
}
}
} else {
for (const status of TASK_STATUSES) {
const tasks = groupedFilteredTasks[status];
if (tasks && tasks.length > 0) {
handleViewTaskDetails(tasks[0]);
break;
}
}
}
}, [selectedTask, groupedFilteredTasks, handleViewTaskDetails]);
const selectPreviousColumn = useCallback(() => {
if (selectedTask) {
const currentIndex = TASK_STATUSES.findIndex(
(status) => status === selectedTask.status
);
for (let i = currentIndex - 1; i >= 0; i--) {
const tasks = groupedFilteredTasks[TASK_STATUSES[i]];
if (tasks && tasks.length > 0) {
handleViewTaskDetails(tasks[0]);
return;
}
}
} else {
for (const status of TASK_STATUSES) {
const tasks = groupedFilteredTasks[status];
if (tasks && tasks.length > 0) {
handleViewTaskDetails(tasks[0]);
break;
}
}
}
}, [selectedTask, groupedFilteredTasks, handleViewTaskDetails]);
const handleDragEnd = useCallback(
async (event: DragEndEvent) => {
const { active, over } = event;
if (!over || !active.data.current) return;
const draggedTaskId = active.id as string;
const newStatus = over.id as Task['status'];
const task = tasksById[draggedTaskId];
if (!task || task.status === newStatus) return;
try {
await tasksApi.update(draggedTaskId, {
title: task.title,
description: task.description,
status: newStatus,
parent_task_attempt: task.parent_task_attempt,
image_ids: null,
});
} catch (err) {
console.error('Failed to update task status:', err);
}
},
[tasksById]
);
const isInitialTasksLoad = isLoading && tasks.length === 0;
if (projectError) {
return (
<div className="p-4">
<Alert>
<AlertTitle className="flex items-center gap-2">
<AlertTriangle size="16" />
{t('common:states.error')}
</AlertTitle>
<AlertDescription>
{projectError.message || 'Failed to load project'}
</AlertDescription>
</Alert>
</div>
);
}
if (projectLoading && isInitialTasksLoad) {
return <Loader message={t('loading')} size={32} className="py-8" />;
}
const truncateTitle = (title: string | undefined, maxLength = 20) => {
if (!title) return 'Task';
if (title.length <= maxLength) return title;
const truncated = title.substring(0, maxLength);
const lastSpace = truncated.lastIndexOf(' ');
return lastSpace > 0
? `${truncated.substring(0, lastSpace)}...`
: `${truncated}...`;
};
const kanbanContent =
tasks.length === 0 ? (
<div className="max-w-7xl mx-auto mt-8">
<Card>
<CardContent className="text-center py-8">
<p className="text-muted-foreground">{t('empty.noTasks')}</p>
<Button className="mt-4" onClick={handleCreateNewTask}>
<Plus className="h-4 w-4 mr-2" />
{t('empty.createFirst')}
</Button>
</CardContent>
</Card>
</div>
) : filteredTasks.length === 0 ? (
<div className="max-w-7xl mx-auto mt-8">
<Card>
<CardContent className="text-center py-8">
<p className="text-muted-foreground">
{t('empty.noSearchResults')}
</p>
</CardContent>
</Card>
</div>
) : (
<div className="w-full h-full overflow-x-auto overflow-y-auto overscroll-x-contain touch-pan-y">
<TaskKanbanBoard
groupedTasks={groupedFilteredTasks}
onDragEnd={handleDragEnd}
onViewTaskDetails={handleViewTaskDetails}
selectedTask={selectedTask || undefined}
onCreateTask={handleCreateNewTask}
/>
</div>
);
const rightHeader = selectedTask ? (
<NewCardHeader
className="shrink-0"
actions={
isTaskView ? (
<TaskPanelHeaderActions
task={selectedTask}
onClose={() =>
navigate(`/projects/${projectId}/tasks`, { replace: true })
}
/>
) : (
<AttemptHeaderActions
mode={mode}
onModeChange={setMode}
task={selectedTask}
attempt={attempt ?? null}
onClose={() =>
navigate(`/projects/${projectId}/tasks`, { replace: true })
}
/>
)
}
>
<div className="mx-auto w-full">
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
{isTaskView ? (
<BreadcrumbPage>
{truncateTitle(selectedTask?.title)}
</BreadcrumbPage>
) : (
<BreadcrumbLink
className="cursor-pointer hover:underline"
onClick={() =>
navigateWithSearch(paths.task(projectId!, taskId!))
}
>
{truncateTitle(selectedTask?.title)}
</BreadcrumbLink>
)}
</BreadcrumbItem>
{!isTaskView && (
<>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>
{attempt?.branch || 'Task Attempt'}
</BreadcrumbPage>
</BreadcrumbItem>
</>
)}
</BreadcrumbList>
</Breadcrumb>
</div>
</NewCardHeader>
) : null;
const attemptContent = selectedTask ? (
<NewCard className="h-full min-h-0 flex flex-col bg-diagonal-lines bg-muted border-0">
{isTaskView ? (
<TaskPanel task={selectedTask} />
) : (
<TaskAttemptPanel attempt={attempt} task={selectedTask}>
{({ logs, followUp }) => (
<>
{gitError && (
<div className="mx-4 mt-4 p-3 bg-red-50 border border-red-200 rounded">
<div className="text-destructive text-sm">{gitError}</div>
</div>
)}
<div className="flex-1 min-h-0 flex flex-col">{logs}</div>
<div className="shrink-0 border-t">
<div className="mx-auto w-full max-w-[50rem]">
<TodoPanel />
</div>
</div>
<div className="shrink-0 border-t">
<div className="mx-auto w-full max-w-[50rem]">{followUp}</div>
</div>
</>
)}
</TaskAttemptPanel>
)}
</NewCard>
) : null;
const auxContent = (
<div className="relative h-full w-full">
{mode === 'preview' && attempt && selectedTask && <PreviewPanel />}
{mode === 'diffs' && attempt && selectedTask && (
<DiffsPanelContainer
attempt={attempt}
selectedTask={selectedTask}
projectId={projectId!}
branchStatus={branchStatus}
branches={branches}
setGitError={setGitError}
/>
)}
</div>
);
const attemptArea = attempt ? (
<ClickedElementsProvider attempt={attempt}>
<ReviewProvider key={attempt.id}>
<ExecutionProcessesProvider key={attempt.id} attemptId={attempt.id}>
<TasksLayout
kanban={kanbanContent}
attempt={attemptContent}
aux={auxContent}
isPanelOpen={isPanelOpen}
mode={mode}
isMobile={isMobile}
rightHeader={rightHeader}
/>
</ExecutionProcessesProvider>
</ReviewProvider>
</ClickedElementsProvider>
) : (
<TasksLayout
kanban={kanbanContent}
attempt={attemptContent}
aux={auxContent}
isPanelOpen={isPanelOpen}
mode={mode}
isMobile={isMobile}
rightHeader={rightHeader}
/>
);
return (
<div className="min-h-full h-full flex flex-col">
{streamError && (
<Alert className="w-full z-30 xl:sticky xl:top-0">
<AlertTitle className="flex items-center gap-2">
<AlertTriangle size="16" />
{t('common:states.reconnecting')}
</AlertTitle>
<AlertDescription>{streamError}</AlertDescription>
</Alert>
)}
<div className="flex-1 min-h-0">{attemptArea}</div>
<FeatureShowcaseModal
isOpen={showTaskPanelShowcase}
onClose={closeTaskPanelShowcase}
config={taskPanelShowcase}
/>
</div>
);
}