Task List 10x Implementation Plan
Task List 10x Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Rebuild the TaskListPanel into a rich heads-up display with dependency visualization, progress bar, inline expand, and task polling.
Architecture: Full rebuild of TaskListPanel as an orchestrator with extracted sub-components (TaskProgressHeader, TaskRow, TaskDetail, TaskActiveForm). Extends useTaskState with 4-tier dependency-aware sorting, task polling tied to background refresh, and status timestamp tracking. Extracts useTabVisibility as a shared hook.
Tech Stack: React 19, Tailwind CSS 4, motion (framer-motion), TanStack Query, Zustand, Vitest + React Testing Library
Spec: docs/superpowers/specs/2026-03-23-task-list-10x-design.md
Key discovery: useElapsedTime and formatElapsed already exist in shared/model/use-elapsed-time.ts — reuse directly, no new elapsed time hook needed.
File Map
| File | Action | Responsibility |
|---|---|---|
apps/client/src/layers/shared/model/use-tab-visibility.ts | Create | Shared tab visibility hook |
apps/client/src/layers/shared/model/index.ts | Modify | Export useTabVisibility |
apps/client/src/layers/features/chat/model/use-task-state.ts | Modify | Add isStreaming param, polling, 4-tier sort, taskMap+statusTimestamps exposure |
apps/client/src/layers/features/chat/model/use-chat-session.ts | Modify | Replace inline visibility with shared useTabVisibility |
apps/client/src/layers/features/chat/ui/TaskActiveForm.tsx | Create | Active form spinner indicator |
apps/client/src/layers/features/chat/ui/TaskProgressHeader.tsx | Create | Progress bar + count + chevron |
apps/client/src/layers/features/chat/ui/TaskRow.tsx | Create | Single task row with expand/collapse + hover + a11y |
apps/client/src/layers/features/chat/ui/TaskDetail.tsx | Create | Expanded accordion content (description, deps, owner, time) |
apps/client/src/layers/features/chat/ui/TaskListPanel.tsx | Rewrite | Orchestrator composing sub-components |
apps/client/src/layers/features/chat/ui/ChatPanel.tsx | Modify | Pass new props to TaskListPanel |
apps/client/src/layers/features/chat/lib/task-utils.ts | Create | Shared isTaskBlocked utility (used by hook + orchestrator) |
apps/client/src/layers/features/chat/__tests__/TaskListPanel.test.tsx | Rewrite | Tests for new component structure |
apps/client/src/layers/features/chat/__tests__/ChatPanel.test.tsx | Modify | Update useTaskState mock |
Task 1: Extract useTabVisibility shared hook
Files:
-
Create:
apps/client/src/layers/shared/model/use-tab-visibility.ts -
Create:
apps/client/src/layers/shared/model/__tests__/use-tab-visibility.test.ts -
Modify:
apps/client/src/layers/shared/model/index.ts -
Modify:
apps/client/src/layers/features/chat/model/use-chat-session.ts -
Step 1: Write the failing test
Create apps/client/src/layers/shared/model/__tests__/use-tab-visibility.test.ts:
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { renderHook, act, cleanup } from '@testing-library/react';
import { useTabVisibility } from '../use-tab-visibility';
afterEach(cleanup);
beforeEach(() => {
Object.defineProperty(document, 'hidden', { value: false, writable: true, configurable: true });
});
describe('useTabVisibility', () => {
it('returns true when document is not hidden', () => {
const { result } = renderHook(() => useTabVisibility());
expect(result.current).toBe(true);
});
it('returns false when document is hidden', () => {
Object.defineProperty(document, 'hidden', { value: true, writable: true, configurable: true });
const { result } = renderHook(() => useTabVisibility());
expect(result.current).toBe(false);
});
it('updates when visibility changes', () => {
const { result } = renderHook(() => useTabVisibility());
expect(result.current).toBe(true);
act(() => {
Object.defineProperty(document, 'hidden', {
value: true,
writable: true,
configurable: true,
});
document.dispatchEvent(new Event('visibilitychange'));
});
expect(result.current).toBe(false);
});
});- Step 2: Run test to verify it fails
Run: pnpm vitest run apps/client/src/layers/shared/model/__tests__/use-tab-visibility.test.ts
Expected: FAIL with "Cannot find module '../use-tab-visibility'"
- Step 3: Implement
useTabVisibility
Create apps/client/src/layers/shared/model/use-tab-visibility.ts:
import { useState, useEffect } from 'react';
/** Track whether the browser tab is visible. Updates reactively on visibilitychange events. */
export function useTabVisibility(): boolean {
const [isVisible, setIsVisible] = useState(() => !document.hidden);
useEffect(() => {
const handler = () => setIsVisible(!document.hidden);
document.addEventListener('visibilitychange', handler);
return () => document.removeEventListener('visibilitychange', handler);
}, []);
return isVisible;
}- Step 4: Export from shared barrel
Add to apps/client/src/layers/shared/model/index.ts:
export { useTabVisibility } from './use-tab-visibility';- Step 5: Run test to verify it passes
Run: pnpm vitest run apps/client/src/layers/shared/model/__tests__/use-tab-visibility.test.ts
Expected: PASS (3 tests)
- Step 6: Update
use-chat-session.tsto use shared hook
In apps/client/src/layers/features/chat/model/use-chat-session.ts:
Replace the inline state + effect (~lines 126, 148-155):
const [isTabVisible, setIsTabVisible] = useState(!document.hidden);and the visibilitychange effect block with:
const isTabVisible = useTabVisibility();Add import: import { useTabVisibility } from '@/layers/shared/model';
Remove the visibilitychange useEffect block (lines 147-155).
- Step 7: Run full test suite to verify no regressions
Run: pnpm vitest run --reporter=verbose 2>&1 | tail -20
Expected: All existing tests pass
- Step 8: Commit
git add apps/client/src/layers/shared/model/use-tab-visibility.ts \
apps/client/src/layers/shared/model/__tests__/use-tab-visibility.test.ts \
apps/client/src/layers/shared/model/index.ts \
apps/client/src/layers/features/chat/model/use-chat-session.ts
git commit -m "refactor(client): extract useTabVisibility to shared model"Task 2: Extend useTaskState with polling, 4-tier sort, and status timestamps
Files:
-
Modify:
apps/client/src/layers/features/chat/model/use-task-state.ts -
Modify:
apps/client/src/layers/features/chat/ui/ChatPanel.tsx(line 43) -
Modify:
apps/client/src/layers/features/chat/__tests__/ChatPanel.test.tsx(line 38-46) -
Step 1: Update
TaskStateinterface and addisTaskBlocked
In apps/client/src/layers/features/chat/model/use-task-state.ts:
Add isTaskBlocked helper function before the hook:
/** Check if a task is blocked by any incomplete dependency. */
function isTaskBlocked(task: TaskItem, taskMap: Map<string, TaskItem>): boolean {
if (!task.blockedBy?.length) return false;
return task.blockedBy.some((depId) => {
const dep = taskMap.get(depId);
return dep && dep.status !== 'completed';
});
}Update sortTasks to accept taskMap and implement 4-tier sort:
function sortTasks(tasks: TaskItem[], taskMap: Map<string, TaskItem>): TaskItem[] {
return [...tasks].sort((a, b) => {
const aOrder =
a.status === 'in_progress'
? 0
: a.status === 'pending' && !isTaskBlocked(a, taskMap)
? 1
: a.status === 'pending'
? 2
: 3; // completed
const bOrder =
b.status === 'in_progress'
? 0
: b.status === 'pending' && !isTaskBlocked(b, taskMap)
? 1
: b.status === 'pending'
? 2
: 3;
return aOrder - bOrder;
});
}Update the TaskState interface:
export interface TaskState {
tasks: TaskItem[];
taskMap: Map<string, TaskItem>;
activeForm: string | null;
isCollapsed: boolean;
toggleCollapse: () => void;
handleTaskEvent: (event: TaskUpdateEvent) => void;
statusTimestamps: Map<string, { status: TaskStatus; since: number }>;
}- Step 2: Add
isStreamingparameter and polling
Change hook signature:
export function useTaskState(sessionId: string | null, isStreaming: boolean = false): TaskState {Merge into the existing import from @/layers/shared/model (line 3 of use-task-state.ts):
import { useTransport, useAppStore, useTabVisibility } from '@/layers/shared/model';
import { QUERY_TIMING } from '@/layers/shared/lib';Update the useQuery call to add refetchInterval:
const enableMessagePolling = useAppStore((s) => s.enableMessagePolling);
const isTabVisible = useTabVisibility();
const { data: initialTasks } = useQuery({
queryKey: ['tasks', sessionId, selectedCwd],
queryFn: () => transport.getTasks(sessionId!, selectedCwd ?? undefined),
staleTime: 30_000,
refetchOnWindowFocus: false,
enabled: !!sessionId,
refetchInterval: () => {
if (!enableMessagePolling) return false;
if (isStreaming) return false;
return isTabVisible
? QUERY_TIMING.ACTIVE_TAB_REFETCH_MS
: QUERY_TIMING.BACKGROUND_TAB_REFETCH_MS;
},
});- Step 3: Add status timestamp tracking
Add a ref for timestamps:
const statusTimestampsRef = useRef<Map<string, { status: TaskStatus; since: number }>>(new Map());Update the useEffect that resets taskMap to also reset timestamps:
useEffect(() => {
setTaskMap(new Map());
nextIdRef.current = 1;
statusTimestampsRef.current = new Map();
if (initialTasks && initialTasks.tasks.length > 0) {
const map = new Map<string, TaskItem>();
const now = Date.now();
for (const task of initialTasks.tasks) {
map.set(task.id, task);
statusTimestampsRef.current.set(task.id, { status: task.status, since: now });
}
setTaskMap(map);
nextIdRef.current = initialTasks.tasks.length + 1;
}
}, [initialTasks]);In handleTaskEvent, update timestamps when tasks are created or change status:
// Inside the create branch, after next.set(id, {...}):
const now = Date.now();
statusTimestampsRef.current.set(id, { status: event.task.status, since: now });
// Inside the update branch, after next.set(event.task.id, merged):
if (event.task.status && event.task.status !== existing.status) {
statusTimestampsRef.current.set(event.task.id, { status: event.task.status, since: Date.now() });
}
// Inside the snapshot branch, after the loop:
const now = Date.now();
statusTimestampsRef.current = new Map();
for (const item of items) {
statusTimestampsRef.current.set(item.id, { status: item.status, since: now });
}- Step 4: Update return value
Update the sorting call and return value:
const allTasks = Array.from(taskMap.values());
const sorted = sortTasks(allTasks, taskMap);
const inProgressTask = allTasks.find((t) => t.status === 'in_progress');
const activeForm = inProgressTask?.activeForm ?? null;
return {
tasks: sorted.slice(0, MAX_VISIBLE),
taskMap,
activeForm,
isCollapsed,
toggleCollapse,
handleTaskEvent,
statusTimestamps: statusTimestampsRef.current,
};- Step 5: Update ChatPanel to pass
isStreaming
In apps/client/src/layers/features/chat/ui/ChatPanel.tsx line 43, keep useTaskState where it is (before useChatSession). The hook's isStreaming defaults to false via the default parameter. On the first render, polling will be enabled (if background refresh is on). Once useChatSession provides status, React re-renders and refetchInterval reads the updated closure. This 1-render lag is harmless since refetchInterval is a function evaluated each cycle.
Do NOT move useTaskState after useChatSession — there's a circular dependency: useChatSession receives handleTaskEventWithCelebrations which uses taskState.
Keep the existing call as-is — the default isStreaming = false is correct:
const taskState = useTaskState(sessionId); // isStreaming defaults to falseThe refetchInterval function captures isStreaming via closure, so even with the default, once the component re-renders after useChatSession provides status, the polling behavior is correct. To explicitly pass the value, add a second useEffect that syncs isStreaming — but this is over-engineering. The default works.
- Step 6: Update ChatPanel test mock
In apps/client/src/layers/features/chat/__tests__/ChatPanel.test.tsx lines 38-46, update the mock:
vi.mock('../model/use-task-state', () => ({
useTaskState: () => ({
tasks: [],
taskMap: new Map(),
activeForm: null,
isCollapsed: true,
toggleCollapse: vi.fn(),
handleTaskEvent: vi.fn(),
statusTimestamps: new Map(),
}),
}));- Step 7: Run tests
Run: pnpm vitest run apps/client/src/layers/features/chat/ --reporter=verbose 2>&1 | tail -30
Expected: Existing tests pass (TaskListPanel tests may need adjustment — that's Task 7)
- Step 8: Commit
git add apps/client/src/layers/features/chat/model/use-task-state.ts \
apps/client/src/layers/features/chat/ui/ChatPanel.tsx \
apps/client/src/layers/features/chat/__tests__/ChatPanel.test.tsx
git commit -m "feat(client): extend useTaskState with polling, 4-tier sort, and timestamps"Task 3: Create TaskActiveForm component
Files:
-
Create:
apps/client/src/layers/features/chat/ui/TaskActiveForm.tsx -
Step 1: Create the component
Extract from current TaskListPanel.tsx lines 41-52:
import { Loader2 } from 'lucide-react';
import { AnimatePresence, motion } from 'motion/react';
interface TaskActiveFormProps {
activeForm: string | null;
isCollapsed: boolean;
}
/** Animated spinner showing the currently active task form name. */
export function TaskActiveForm({ activeForm, isCollapsed }: TaskActiveFormProps) {
return (
<AnimatePresence>
{activeForm && !isCollapsed && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
className="mb-1 flex items-center gap-2 text-xs text-blue-400"
>
<Loader2 className="size-(--size-icon-xs) shrink-0 animate-spin" />
<span className="truncate">{activeForm}</span>
</motion.div>
)}
</AnimatePresence>
);
}- Step 2: Verify it compiles
Run: pnpm vitest run apps/client/src/layers/features/chat/__tests__/TaskListPanel.test.tsx
Expected: PASS (existing tests still pass since we haven't changed TaskListPanel yet)
- Step 3: Commit
git add apps/client/src/layers/features/chat/ui/TaskActiveForm.tsx
git commit -m "refactor(client): extract TaskActiveForm component"Task 4: Create TaskProgressHeader component
Files:
-
Create:
apps/client/src/layers/features/chat/ui/TaskProgressHeader.tsx -
Create:
apps/client/src/layers/features/chat/__tests__/TaskProgressHeader.test.tsx -
Step 1: Write the failing tests
Create apps/client/src/layers/features/chat/__tests__/TaskProgressHeader.test.tsx:
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, cleanup, fireEvent } from '@testing-library/react';
import React from 'react';
import { TaskProgressHeader } from '../ui/TaskProgressHeader';
import type { TaskItem } from '@dorkos/shared/types';
afterEach(cleanup);
const makeTasks = (counts: { done: number; active: number; pending: number }): TaskItem[] => {
const tasks: TaskItem[] = [];
let id = 1;
for (let i = 0; i < counts.done; i++) tasks.push({ id: String(id++), subject: `Done ${i}`, status: 'completed' });
for (let i = 0; i < counts.active; i++) tasks.push({ id: String(id++), subject: `Active ${i}`, status: 'in_progress' });
for (let i = 0; i < counts.pending; i++) tasks.push({ id: String(id++), subject: `Pending ${i}`, status: 'pending' });
return tasks;
};
describe('TaskProgressHeader', () => {
it('shows correct fraction count', () => {
render(<TaskProgressHeader tasks={makeTasks({ done: 3, active: 1, pending: 3 })} isCollapsed={false} onToggleCollapse={() => {}} />);
expect(screen.getByText('3/7 tasks')).toBeDefined();
});
it('shows singular for 1 task', () => {
render(<TaskProgressHeader tasks={makeTasks({ done: 0, active: 0, pending: 1 })} isCollapsed={false} onToggleCollapse={() => {}} />);
expect(screen.getByText('0/1 task')).toBeDefined();
});
it('renders progress bar with correct width', () => {
const { container } = render(<TaskProgressHeader tasks={makeTasks({ done: 2, active: 0, pending: 2 })} isCollapsed={false} onToggleCollapse={() => {}} />);
const fill = container.querySelector('[data-slot="progress-fill"]');
expect(fill).not.toBeNull();
expect(fill?.getAttribute('style')).toContain('width: 50%');
});
it('uses green color when all tasks complete', () => {
const { container } = render(<TaskProgressHeader tasks={makeTasks({ done: 5, active: 0, pending: 0 })} isCollapsed={false} onToggleCollapse={() => {}} />);
const fill = container.querySelector('[data-slot="progress-fill"]');
expect(fill?.className).toContain('bg-green-500');
});
it('uses blue color when tasks remain', () => {
const { container } = render(<TaskProgressHeader tasks={makeTasks({ done: 2, active: 1, pending: 2 })} isCollapsed={false} onToggleCollapse={() => {}} />);
const fill = container.querySelector('[data-slot="progress-fill"]');
expect(fill?.className).toContain('bg-blue-500');
});
it('calls onToggleCollapse when clicked', () => {
const onToggle = vi.fn();
render(<TaskProgressHeader tasks={makeTasks({ done: 1, active: 0, pending: 1 })} isCollapsed={false} onToggleCollapse={onToggle} />);
fireEvent.click(screen.getByRole('button'));
expect(onToggle).toHaveBeenCalledOnce();
});
it('shows ChevronRight when collapsed', () => {
const { container } = render(<TaskProgressHeader tasks={makeTasks({ done: 1, active: 0, pending: 1 })} isCollapsed={true} onToggleCollapse={() => {}} />);
// Lucide renders SVGs — check for the icon's class or data attribute
expect(container.querySelector('svg')).not.toBeNull();
});
});- Step 2: Run tests to verify they fail
Run: pnpm vitest run apps/client/src/layers/features/chat/__tests__/TaskProgressHeader.test.tsx
Expected: FAIL with "Cannot find module '../ui/TaskProgressHeader'"
- Step 3: Implement
TaskProgressHeader
Create apps/client/src/layers/features/chat/ui/TaskProgressHeader.tsx:
import { ChevronDown, ChevronRight } from 'lucide-react';
import type { TaskItem } from '@dorkos/shared/types';
interface TaskProgressHeaderProps {
tasks: TaskItem[];
isCollapsed: boolean;
onToggleCollapse: () => void;
}
/** Compact progress header with animated bar, fraction count, and collapse toggle. */
export function TaskProgressHeader({ tasks, isCollapsed, onToggleCollapse }: TaskProgressHeaderProps) {
const total = tasks.length;
const done = tasks.filter((t) => t.status === 'completed').length;
const allDone = done === total && total > 0;
const pct = total > 0 ? (done / total) * 100 : 0;
return (
<button
onClick={onToggleCollapse}
className="text-muted-foreground hover:text-foreground flex w-full items-center gap-2 text-xs"
>
{isCollapsed ? (
<ChevronRight className="size-(--size-icon-xs) shrink-0" />
) : (
<ChevronDown className="size-(--size-icon-xs) shrink-0" />
)}
<div className="bg-muted h-0.5 flex-1 overflow-hidden rounded-full">
<div
data-slot="progress-fill"
className={`h-full rounded-full transition-all duration-300 ease-out ${allDone ? 'bg-green-500' : 'bg-blue-500'}`}
style={{ width: `${pct}%` }}
/>
</div>
<span className="shrink-0 tabular-nums">
{done}/{total} {total === 1 ? 'task' : 'tasks'}
</span>
</button>
);
}- Step 4: Run tests to verify they pass
Run: pnpm vitest run apps/client/src/layers/features/chat/__tests__/TaskProgressHeader.test.tsx
Expected: PASS (7 tests)
- Step 5: Commit
git add apps/client/src/layers/features/chat/ui/TaskProgressHeader.tsx \
apps/client/src/layers/features/chat/__tests__/TaskProgressHeader.test.tsx
git commit -m "feat(client): add TaskProgressHeader with progress bar"Task 5: Create TaskDetail component
Files:
-
Create:
apps/client/src/layers/features/chat/ui/TaskDetail.tsx -
Create:
apps/client/src/layers/features/chat/__tests__/TaskDetail.test.tsx -
Step 1: Write the failing tests
Create apps/client/src/layers/features/chat/__tests__/TaskDetail.test.tsx:
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, cleanup, fireEvent } from '@testing-library/react';
import React from 'react';
import { TaskDetail } from '../ui/TaskDetail';
import type { TaskItem } from '@dorkos/shared/types';
afterEach(cleanup);
const makeMap = (tasks: TaskItem[]): Map<string, TaskItem> =>
new Map(tasks.map((t) => [t.id, t]));
const taskA: TaskItem = { id: '1', subject: 'Task A', status: 'in_progress', description: 'Doing important work' };
const taskB: TaskItem = { id: '2', subject: 'Task B', status: 'pending', blockedBy: ['1'] };
const taskC: TaskItem = { id: '3', subject: 'Task C', status: 'pending', owner: 'sub-agent-1' };
describe('TaskDetail', () => {
it('renders description when present', () => {
render(<TaskDetail task={taskA} taskMap={makeMap([taskA])} statusSince={Date.now() - 5000} onScrollToTask={() => {}} />);
expect(screen.getByText('Doing important work')).toBeDefined();
});
it('omits description section when not present', () => {
const noDesc: TaskItem = { id: '4', subject: 'No desc', status: 'pending' };
render(<TaskDetail task={noDesc} taskMap={makeMap([noDesc])} statusSince={Date.now()} onScrollToTask={() => {}} />);
expect(screen.queryByText('Doing important work')).toBeNull();
});
it('shows blocked-by dependencies with task subjects', () => {
const map = makeMap([taskA, taskB]);
render(<TaskDetail task={taskB} taskMap={map} statusSince={Date.now()} onScrollToTask={() => {}} />);
expect(screen.getByText(/Task A/)).toBeDefined();
});
it('shows owner when present', () => {
render(<TaskDetail task={taskC} taskMap={makeMap([taskC])} statusSince={Date.now()} onScrollToTask={() => {}} />);
expect(screen.getByText('sub-agent-1')).toBeDefined();
});
it('calls onScrollToTask when dependency is clicked', () => {
const onScroll = vi.fn();
const map = makeMap([taskA, taskB]);
render(<TaskDetail task={taskB} taskMap={map} statusSince={Date.now()} onScrollToTask={onScroll} />);
fireEvent.click(screen.getByText('Task A'));
expect(onScroll).toHaveBeenCalledWith('1');
});
it('shows elapsed time', () => {
render(<TaskDetail task={taskA} taskMap={makeMap([taskA])} statusSince={Date.now() - 65000} onScrollToTask={() => {}} />);
// Should show ~1m 05s
expect(screen.getByText(/1m/)).toBeDefined();
});
});- Step 2: Run tests to verify they fail
Run: pnpm vitest run apps/client/src/layers/features/chat/__tests__/TaskDetail.test.tsx
Expected: FAIL
- Step 3: Implement
TaskDetail
Create apps/client/src/layers/features/chat/ui/TaskDetail.tsx:
import { motion } from 'motion/react';
import type { TaskItem } from '@dorkos/shared/types';
import { useElapsedTime } from '@/layers/shared/model';
interface TaskDetailProps {
task: TaskItem;
taskMap: Map<string, TaskItem>;
statusSince: number | null;
onScrollToTask: (taskId: string) => void;
}
const STATUS_PREFIX: Record<string, string> = {
in_progress: '',
pending: 'waiting ',
completed: 'done ',
};
/** Expanded accordion content showing description, elapsed time, owner, and dependencies. */
export function TaskDetail({ task, taskMap, statusSince, onScrollToTask }: TaskDetailProps) {
const elapsed = useElapsedTime(statusSince);
const prefix = STATUS_PREFIX[task.status] ?? '';
const blockedByTasks = task.blockedBy
?.map((id) => taskMap.get(id))
.filter((t): t is TaskItem => t != null) ?? [];
const blocksTasks = task.blocks
?.map((id) => taskMap.get(id))
.filter((t): t is TaskItem => t != null) ?? [];
const metaItems: React.ReactNode[] = [];
if (statusSince !== null) {
metaItems.push(<span key="time">{prefix}{elapsed.formatted}</span>);
}
if (task.owner) {
metaItems.push(<span key="owner">{task.owner}</span>);
}
if (blockedByTasks.length > 0) {
metaItems.push(
<span key="blocked-by" className="inline-flex items-center gap-1">
{'← '}
{blockedByTasks.map((dep, i) => (
<span key={dep.id}>
<button
onClick={(e) => { e.stopPropagation(); onScrollToTask(dep.id); }}
className="hover:text-foreground underline decoration-dotted"
>
{dep.subject}
</button>
{i < blockedByTasks.length - 1 && ', '}
</span>
))}
</span>
);
}
if (blocksTasks.length > 0) {
metaItems.push(
<span key="blocks" className="inline-flex items-center gap-1">
{'→ '}
{blocksTasks.map((dep, i) => (
<span key={dep.id}>
<button
onClick={(e) => { e.stopPropagation(); onScrollToTask(dep.id); }}
className="hover:text-foreground underline decoration-dotted"
>
{dep.subject}
</button>
{i < blocksTasks.length - 1 && ', '}
</span>
))}
</span>
);
}
return (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
className="ml-6 mt-0.5 space-y-1"
>
{task.description && (
<p className="text-muted-foreground whitespace-pre-wrap text-xs">{task.description}</p>
)}
{metaItems.length > 0 && (
<div className="text-muted-foreground flex flex-wrap items-center gap-1 text-[11px]">
{metaItems.map((item, i) => (
<span key={i} className="flex items-center gap-1">
{i > 0 && <span className="text-muted-foreground/50">·</span>}
{item}
</span>
))}
</div>
)}
</motion.div>
);
}Note: The existing useElapsedTime from shared/model always ticks at 1s. For pending/completed tasks, the 1s tick is acceptable overhead since only one task is expanded at a time.
- Step 4: Run tests to verify they pass
Run: pnpm vitest run apps/client/src/layers/features/chat/__tests__/TaskDetail.test.tsx
Expected: PASS (6 tests)
- Step 5: Commit
git add apps/client/src/layers/features/chat/ui/TaskDetail.tsx \
apps/client/src/layers/features/chat/__tests__/TaskDetail.test.tsx
git commit -m "feat(client): add TaskDetail with description, deps, owner, elapsed time"Task 6: Create TaskRow component
Files:
-
Create:
apps/client/src/layers/features/chat/ui/TaskRow.tsx -
Create:
apps/client/src/layers/features/chat/__tests__/TaskRow.test.tsx -
Step 1: Write the failing tests
Create apps/client/src/layers/features/chat/__tests__/TaskRow.test.tsx:
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, cleanup, fireEvent } from '@testing-library/react';
import React from 'react';
import { TaskRow } from '../ui/TaskRow';
import type { TaskItem } from '@dorkos/shared/types';
afterEach(cleanup);
const baseProps = {
isBlocked: false,
isExpanded: false,
onToggleExpand: vi.fn(),
onHover: vi.fn(),
isHighlightedAsDep: false,
isHighlightedAsDependent: false,
taskMap: new Map<string, TaskItem>(),
statusSince: null,
isCelebrating: false,
onScrollToTask: vi.fn(),
};
const pendingTask: TaskItem = { id: '1', subject: 'Pending task', status: 'pending' };
const activeTask: TaskItem = { id: '2', subject: 'Active task', status: 'in_progress' };
const doneTask: TaskItem = { id: '3', subject: 'Done task', status: 'completed' };
describe('TaskRow', () => {
it('renders task subject', () => {
render(<TaskRow task={pendingTask} {...baseProps} />);
expect(screen.getByText('Pending task')).toBeDefined();
});
it('applies bold styling to in-progress tasks', () => {
render(<TaskRow task={activeTask} {...baseProps} />);
const row = screen.getByRole('button');
expect(row.className).toContain('font-medium');
});
it('applies line-through to completed tasks', () => {
render(<TaskRow task={doneTask} {...baseProps} />);
const row = screen.getByRole('button');
expect(row.className).toContain('line-through');
});
it('dims blocked pending tasks', () => {
render(<TaskRow task={pendingTask} {...baseProps} isBlocked={true} />);
const row = screen.getByRole('button');
expect(row.className).toContain('text-muted-foreground/50');
});
it('calls onToggleExpand when clicked', () => {
const onToggle = vi.fn();
render(<TaskRow task={pendingTask} {...baseProps} onToggleExpand={onToggle} />);
fireEvent.click(screen.getByRole('button'));
expect(onToggle).toHaveBeenCalledOnce();
});
it('toggles on Enter key', () => {
const onToggle = vi.fn();
render(<TaskRow task={pendingTask} {...baseProps} onToggleExpand={onToggle} />);
fireEvent.keyDown(screen.getByRole('button'), { key: 'Enter' });
expect(onToggle).toHaveBeenCalledOnce();
});
it('has correct aria-expanded attribute', () => {
render(<TaskRow task={pendingTask} {...baseProps} isExpanded={true} />);
expect(screen.getByRole('button').getAttribute('aria-expanded')).toBe('true');
});
it('applies blue border when highlighted as dependency', () => {
render(<TaskRow task={pendingTask} {...baseProps} isHighlightedAsDep={true} />);
const row = screen.getByRole('button');
expect(row.className).toContain('border-blue-400');
});
it('applies amber border when highlighted as dependent', () => {
render(<TaskRow task={pendingTask} {...baseProps} isHighlightedAsDependent={true} />);
const row = screen.getByRole('button');
expect(row.className).toContain('border-amber-400');
});
it('calls onHover with task id on mouse enter', () => {
const onHover = vi.fn();
render(<TaskRow task={pendingTask} {...baseProps} onHover={onHover} />);
fireEvent.mouseEnter(screen.getByRole('button'));
expect(onHover).toHaveBeenCalledWith('1');
});
it('calls onHover with null on mouse leave', () => {
const onHover = vi.fn();
render(<TaskRow task={pendingTask} {...baseProps} onHover={onHover} />);
fireEvent.mouseLeave(screen.getByRole('button'));
expect(onHover).toHaveBeenCalledWith(null);
});
it('renders data-task-id attribute', () => {
render(<TaskRow task={pendingTask} {...baseProps} />);
expect(screen.getByRole('button').getAttribute('data-task-id')).toBe('1');
});
});- Step 2: Run tests to verify they fail
Run: pnpm vitest run apps/client/src/layers/features/chat/__tests__/TaskRow.test.tsx
Expected: FAIL
- Step 3: Implement
TaskRow
Create apps/client/src/layers/features/chat/ui/TaskRow.tsx:
import { Loader2, Circle, CheckCircle2 } from 'lucide-react';
import { AnimatePresence, motion } from 'motion/react';
import { cn } from '@/layers/shared/lib';
import type { TaskItem, TaskStatus } from '@dorkos/shared/types';
import { TaskDetail } from './TaskDetail';
const STATUS_ICON: Record<TaskStatus, React.ReactNode> = {
in_progress: <Loader2 className="size-(--size-icon-xs) shrink-0 animate-spin text-blue-400" />,
pending: <Circle className="text-muted-foreground size-(--size-icon-xs) shrink-0" />,
completed: <CheckCircle2 className="size-(--size-icon-xs) shrink-0 text-green-500" />,
};
interface TaskRowProps {
task: TaskItem;
isBlocked: boolean;
isExpanded: boolean;
onToggleExpand: () => void;
onHover: (taskId: string | null) => void;
isHighlightedAsDep: boolean;
isHighlightedAsDependent: boolean;
taskMap: Map<string, TaskItem>;
statusSince: number | null;
isCelebrating: boolean;
onCelebrationComplete?: () => void;
onScrollToTask: (taskId: string) => void;
}
/** Single task row with status icon, expand/collapse, hover dep highlights, and a11y. */
export function TaskRow({
task,
isBlocked,
isExpanded,
onToggleExpand,
onHover,
isHighlightedAsDep,
isHighlightedAsDependent,
taskMap,
statusSince,
isCelebrating,
onCelebrationComplete,
onScrollToTask,
}: TaskRowProps) {
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onToggleExpand();
}
};
return (
<motion.li data-task-id={task.id}>
<div
role="button"
tabIndex={0}
aria-expanded={isExpanded}
onClick={onToggleExpand}
onKeyDown={handleKeyDown}
onMouseEnter={() => onHover(task.id)}
onMouseLeave={() => onHover(null)}
className={cn(
'relative flex items-center gap-2 rounded py-0.5 text-xs transition-colors',
task.status === 'completed' && 'text-muted-foreground/50 line-through',
task.status === 'in_progress' && 'text-foreground font-medium',
task.status === 'pending' && !isBlocked && 'text-muted-foreground',
task.status === 'pending' && isBlocked && 'text-muted-foreground/50',
isHighlightedAsDep && 'border-l-2 border-blue-400 pl-1.5',
isHighlightedAsDependent && 'border-l-2 border-amber-400 pl-1.5',
!isHighlightedAsDep && !isHighlightedAsDependent && 'border-l-2 border-transparent pl-1.5',
)}
>
{/* Celebration shimmer */}
{isCelebrating && (
<motion.div
aria-hidden="true"
className="absolute inset-0 rounded"
initial={{ backgroundPosition: '-200% 0' }}
animate={{ backgroundPosition: '200% 0' }}
transition={{ duration: 0.4, ease: 'easeOut' }}
style={{
backgroundImage: 'linear-gradient(90deg, transparent 0%, rgba(255,215,0,0.2) 50%, transparent 100%)',
backgroundSize: '200% 100%',
}}
/>
)}
{/* Celebration checkmark spring-pop */}
{isCelebrating ? (
<motion.span
initial={{ scale: 1 }}
animate={{ scale: [1, 1.4, 1] }}
transition={{ type: 'spring', stiffness: 400, damping: 10 }}
onAnimationComplete={() => onCelebrationComplete?.()}
>
{STATUS_ICON[task.status]}
</motion.span>
) : (
STATUS_ICON[task.status]
)}
<span className="truncate">{task.subject}</span>
</div>
<AnimatePresence>
{isExpanded && (
<TaskDetail
task={task}
taskMap={taskMap}
statusSince={statusSince}
onScrollToTask={onScrollToTask}
/>
)}
</AnimatePresence>
</motion.li>
);
}- Step 4: Run tests to verify they pass
Run: pnpm vitest run apps/client/src/layers/features/chat/__tests__/TaskRow.test.tsx
Expected: PASS (12 tests)
- Step 5: Commit
git add apps/client/src/layers/features/chat/ui/TaskRow.tsx \
apps/client/src/layers/features/chat/__tests__/TaskRow.test.tsx
git commit -m "feat(client): add TaskRow with expand, hover highlights, and a11y"Task 7: Rewrite TaskListPanel as orchestrator + update tests
Files:
-
Rewrite:
apps/client/src/layers/features/chat/ui/TaskListPanel.tsx -
Rewrite:
apps/client/src/layers/features/chat/__tests__/TaskListPanel.test.tsx -
Step 1: Rewrite
TaskListPanel
Replace the contents of apps/client/src/layers/features/chat/ui/TaskListPanel.tsx:
import { useState, useCallback } from 'react';
import { AnimatePresence, motion } from 'motion/react';
import type { TaskItem } from '@dorkos/shared/types';
import { TaskProgressHeader } from './TaskProgressHeader';
import { TaskActiveForm } from './TaskActiveForm';
import { TaskRow } from './TaskRow';
interface TaskListPanelProps {
tasks: TaskItem[];
taskMap: Map<string, TaskItem>;
activeForm: string | null;
isCollapsed: boolean;
onToggleCollapse: () => void;
celebratingTaskId?: string | null;
onCelebrationComplete?: () => void;
statusTimestamps: Map<string, { status: string; since: number }>;
}
function isTaskBlocked(task: TaskItem, taskMap: Map<string, TaskItem>): boolean {
if (!task.blockedBy?.length) return false;
return task.blockedBy.some((depId) => {
const dep = taskMap.get(depId);
return dep && dep.status !== 'completed';
});
}
const MAX_VISIBLE = 10;
/** Orchestrator composing progress header, active form, and task rows with dependency visualization. */
export function TaskListPanel({
tasks,
taskMap,
activeForm,
isCollapsed,
onToggleCollapse,
celebratingTaskId,
onCelebrationComplete,
statusTimestamps,
}: TaskListPanelProps) {
if (tasks.length === 0) return null;
const [expandedTaskId, setExpandedTaskId] = useState<string | null>(null);
const [hoveredTaskId, setHoveredTaskId] = useState<string | null>(null);
const visibleTasks = tasks.slice(0, MAX_VISIBLE);
const handleToggleExpand = useCallback((taskId: string) => {
setExpandedTaskId((prev) => (prev === taskId ? null : taskId));
}, []);
const handleScrollToTask = useCallback((taskId: string) => {
const el = document.querySelector(`[data-task-id="${taskId}"]`);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
el.classList.add('bg-blue-500/10');
setTimeout(() => el.classList.remove('bg-blue-500/10'), 1000);
}
}, []);
// Pre-compute hover highlights
const hoveredTask = hoveredTaskId ? taskMap.get(hoveredTaskId) : null;
return (
<div className="border-t px-4 py-2">
<TaskActiveForm activeForm={activeForm} isCollapsed={isCollapsed} />
<TaskProgressHeader
tasks={tasks}
isCollapsed={isCollapsed}
onToggleCollapse={onToggleCollapse}
/>
<AnimatePresence>
{!isCollapsed && (
<motion.ul
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
className="mt-1 space-y-0.5"
>
{visibleTasks.map((task) => {
const isCelebrating = task.id === celebratingTaskId && task.status === 'completed';
const blocked = isTaskBlocked(task, taskMap);
const timestamp = statusTimestamps.get(task.id);
// Hover highlight computation
const isHighlightedAsDep = hoveredTask?.blockedBy?.includes(task.id) ?? false;
const isHighlightedAsDependent = hoveredTask?.blocks?.includes(task.id) ?? false;
return (
<TaskRow
key={task.id}
task={task}
isBlocked={blocked}
isExpanded={expandedTaskId === task.id}
onToggleExpand={() => handleToggleExpand(task.id)}
onHover={setHoveredTaskId}
isHighlightedAsDep={isHighlightedAsDep}
isHighlightedAsDependent={isHighlightedAsDependent}
taskMap={taskMap}
statusSince={timestamp?.since ?? null}
isCelebrating={isCelebrating}
onCelebrationComplete={onCelebrationComplete}
onScrollToTask={handleScrollToTask}
/>
);
})}
</motion.ul>
)}
</AnimatePresence>
</div>
);
}- Step 2: Update
TaskListPanel.test.tsx
Rewrite the tests to match the new interface (added taskMap and statusTimestamps props):
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, cleanup, fireEvent } from '@testing-library/react';
import React from 'react';
import { TaskListPanel } from '../ui/TaskListPanel';
import type { TaskItem } from '@dorkos/shared/types';
afterEach(cleanup);
const baseTasks: TaskItem[] = [
{ id: '1', subject: 'Completed task', status: 'completed' },
{ id: '2', subject: 'In progress task', status: 'in_progress', activeForm: 'Working on it' },
{ id: '3', subject: 'Pending task', status: 'pending' },
];
const makeMap = (tasks: TaskItem[]) => new Map(tasks.map((t) => [t.id, t]));
const emptyTimestamps = new Map<string, { status: string; since: number }>();
const baseProps = {
activeForm: null as string | null,
isCollapsed: false,
onToggleCollapse: vi.fn(),
statusTimestamps: emptyTimestamps,
};
describe('TaskListPanel', () => {
it('renders nothing when tasks array is empty', () => {
const { container } = render(
<TaskListPanel tasks={[]} taskMap={new Map()} {...baseProps} />
);
expect(container.innerHTML).toBe('');
});
it('shows correct progress count in header', () => {
render(<TaskListPanel tasks={baseTasks} taskMap={makeMap(baseTasks)} {...baseProps} />);
expect(screen.getByText('1/3 tasks')).toBeDefined();
});
it('renders all task subjects', () => {
render(<TaskListPanel tasks={baseTasks} taskMap={makeMap(baseTasks)} {...baseProps} />);
expect(screen.getByText('Completed task')).toBeDefined();
expect(screen.getByText('In progress task')).toBeDefined();
expect(screen.getByText('Pending task')).toBeDefined();
});
it('applies line-through styling to completed tasks', () => {
render(<TaskListPanel tasks={baseTasks} taskMap={makeMap(baseTasks)} {...baseProps} />);
const row = screen.getByText('Completed task').closest('[role="button"]');
expect(row?.className).toContain('line-through');
});
it('applies bold styling to in-progress tasks', () => {
render(<TaskListPanel tasks={baseTasks} taskMap={makeMap(baseTasks)} {...baseProps} />);
const row = screen.getByText('In progress task').closest('[role="button"]');
expect(row?.className).toContain('font-medium');
});
it('shows activeForm spinner text when provided', () => {
render(<TaskListPanel tasks={baseTasks} taskMap={makeMap(baseTasks)} {...baseProps} activeForm="Working on it" />);
expect(screen.getByText('Working on it')).toBeDefined();
});
it('hides task list when collapsed', () => {
render(<TaskListPanel tasks={baseTasks} taskMap={makeMap(baseTasks)} {...baseProps} isCollapsed={true} />);
expect(screen.getByText('1/3 tasks')).toBeDefined();
expect(screen.queryByText('Completed task')).toBeNull();
});
it('calls onToggleCollapse when header is clicked', () => {
const onToggle = vi.fn();
render(<TaskListPanel tasks={baseTasks} taskMap={makeMap(baseTasks)} {...baseProps} onToggleCollapse={onToggle} />);
// The first button is the header
const buttons = screen.getAllByRole('button');
fireEvent.click(buttons[0]);
expect(onToggle).toHaveBeenCalledOnce();
});
it('expands task detail when task row is clicked', () => {
const taskWithDesc: TaskItem[] = [
{ id: '1', subject: 'Task with detail', status: 'pending', description: 'Detailed info' },
];
render(<TaskListPanel tasks={taskWithDesc} taskMap={makeMap(taskWithDesc)} {...baseProps} />);
// Click the task row (second button after header)
const buttons = screen.getAllByRole('button');
fireEvent.click(buttons[1]);
expect(screen.getByText('Detailed info')).toBeDefined();
});
it('dims blocked tasks', () => {
const tasks: TaskItem[] = [
{ id: '1', subject: 'Unblocked', status: 'pending' },
{ id: '2', subject: 'Blocked', status: 'pending', blockedBy: ['1'] },
];
render(<TaskListPanel tasks={tasks} taskMap={makeMap(tasks)} {...baseProps} />);
const blockedRow = screen.getByText('Blocked').closest('[role="button"]');
expect(blockedRow?.className).toContain('text-muted-foreground/50');
});
it('shows celebration effects on completing task', () => {
const tasks: TaskItem[] = [{ id: '1', subject: 'Done task', status: 'completed' }];
render(
<TaskListPanel tasks={tasks} taskMap={makeMap(tasks)} {...baseProps} celebratingTaskId="1" />
);
const shimmer = document.querySelector('[aria-hidden="true"]');
expect(shimmer).not.toBeNull();
});
it('handles singular task count', () => {
const singleTask: TaskItem[] = [{ id: '1', subject: 'Only task', status: 'pending' }];
render(<TaskListPanel tasks={singleTask} taskMap={makeMap(singleTask)} {...baseProps} />);
expect(screen.getByText('0/1 task')).toBeDefined();
});
});- Step 3: Run tests
Run: pnpm vitest run apps/client/src/layers/features/chat/__tests__/TaskListPanel.test.tsx
Expected: PASS
- Step 4: Update ChatPanel to pass new props
In apps/client/src/layers/features/chat/ui/ChatPanel.tsx, update the <TaskListPanel> render (~line 397) to pass taskMap and statusTimestamps:
<TaskListPanel
tasks={taskState.tasks}
taskMap={taskState.taskMap}
activeForm={taskState.activeForm}
isCollapsed={taskState.isCollapsed}
onToggleCollapse={taskState.toggleCollapse}
celebratingTaskId={celebrations.celebratingTaskId}
onCelebrationComplete={celebrations.clearCelebration}
statusTimestamps={taskState.statusTimestamps}
/>- Step 5: Run full test suite
Run: pnpm vitest run --reporter=verbose 2>&1 | tail -30
Expected: All tests pass
- Step 6: Commit
git add apps/client/src/layers/features/chat/ui/TaskListPanel.tsx \
apps/client/src/layers/features/chat/__tests__/TaskListPanel.test.tsx \
apps/client/src/layers/features/chat/ui/ChatPanel.tsx
git commit -m "feat(client): rebuild TaskListPanel with progress bar, deps, and expand"Task 8: Final verification
- Step 1: Typecheck
Run: pnpm typecheck
Expected: Clean — no type errors
- Step 2: Lint
Run: pnpm lint
Expected: Clean (or only pre-existing warnings)
- Step 3: Full test suite
Run: pnpm test -- --run
Expected: All tests pass across all packages
- Step 4: Delete old test file if needed
If the old TaskListPanel.test.tsx was at a different path than what was rewritten, remove it. The test file is at apps/client/src/layers/features/chat/__tests__/TaskListPanel.test.tsx — this was rewritten in Task 7, so no deletion needed.
- Step 5: Commit any remaining fixes
If typecheck/lint revealed issues, fix and commit:
git add -A
git commit -m "fix(client): resolve typecheck and lint issues from task list rebuild"Dependency Graph
Task 1 (useTabVisibility) ──┐
├── Task 2 (useTaskState extensions) ──┐
Task 3 (TaskActiveForm) ────┤ │
Task 4 (TaskProgressHeader) ┤ ├── Task 7 (Orchestrator + Tests)
Task 5 (TaskDetail) ─────── ┤ │
└─── Task 6 (TaskRow, imports TaskDetail) │
└── Task 8 (Final verification)Parallelizable: Tasks 3, 4, 5 can run in parallel after Task 1 is complete. Task 6 depends on Task 5 (imports TaskDetail). Task 2 depends on Task 1. Task 7 depends on Tasks 2-6. Task 8 depends on Task 7.