DorkOS
SuperpowersPlans

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

FileActionResponsibility
apps/client/src/layers/shared/model/use-tab-visibility.tsCreateShared tab visibility hook
apps/client/src/layers/shared/model/index.tsModifyExport useTabVisibility
apps/client/src/layers/features/chat/model/use-task-state.tsModifyAdd isStreaming param, polling, 4-tier sort, taskMap+statusTimestamps exposure
apps/client/src/layers/features/chat/model/use-chat-session.tsModifyReplace inline visibility with shared useTabVisibility
apps/client/src/layers/features/chat/ui/TaskActiveForm.tsxCreateActive form spinner indicator
apps/client/src/layers/features/chat/ui/TaskProgressHeader.tsxCreateProgress bar + count + chevron
apps/client/src/layers/features/chat/ui/TaskRow.tsxCreateSingle task row with expand/collapse + hover + a11y
apps/client/src/layers/features/chat/ui/TaskDetail.tsxCreateExpanded accordion content (description, deps, owner, time)
apps/client/src/layers/features/chat/ui/TaskListPanel.tsxRewriteOrchestrator composing sub-components
apps/client/src/layers/features/chat/ui/ChatPanel.tsxModifyPass new props to TaskListPanel
apps/client/src/layers/features/chat/lib/task-utils.tsCreateShared isTaskBlocked utility (used by hook + orchestrator)
apps/client/src/layers/features/chat/__tests__/TaskListPanel.test.tsxRewriteTests for new component structure
apps/client/src/layers/features/chat/__tests__/ChatPanel.test.tsxModifyUpdate 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.ts to 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 TaskState interface and add isTaskBlocked

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 isStreaming parameter 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 false

The 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.