Files
workspace/docs/2026-05-08-task-ux-evolution-plan.md
Roberto 20240c5fea docs: add Task UX Evolution implementation plan
29 tasks across 7 phases (schema+backend, building blocks, detail sheet,
form dialog, table+pager+list view, project page, i18n). Each task is a
single commit, manual smoke verify in dev (no test suite).
2026-05-08 13:05:58 +02:00

93 KiB
Raw Permalink Blame History

Task UX Evolution — 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.

Spec: 2026-05-08-task-ux-evolution-design.md (commit 3104103)

Goal: Ship a paginated shadcn Table list view for tasks with shared pagination, a right-side detail Sheet with attachments, a redesigned quick-capture create/edit dialog, and reuse the same list view on the project detail page (replacing the current Kanban board there).

Architecture: New shared TaskListView orchestrator owns toolbar + table/grid + pager state. Tasks page and project detail page both render it (project page uses hideProjectColumn). Detail moves from Dialog to Sheet with three fixed regions (sticky header, scrolling body, sticky composer). Attachments are copied into Electron userData/attachments/<taskId>/ and managed by a new taskAttachments tRPC sub-router.

Tech stack: React 19, TanStack Router, shadcn/ui (Table, Sheet, ContextMenu, DropdownMenu, Popover, Calendar), Tailwind 4, lucide-react, TypeScript, tRPC v11, Drizzle ORM + better-sqlite3, Electron dialog + shell + fs/promises, react-i18next.

Test infrastructure note: adiuvAI has no automated test suite. Verification per task uses npm run lint (must pass) and a manual smoke check in npm start (dev with hot reload). Lessons-learned slots are filled during execution.

Repo note: All paths below are relative to the adiuvAI/ submodule root unless otherwise stated. Commit each task in the submodule. The plan file itself lives in the workspace root docs/.

Discovered detail (not in original spec): the project detail page currently renders a KanbanBoard, not a list of TaskRows. Replacing it with TaskListView removes the Kanban board entirely (intended per user choice "Replace + hide Project col"). KanbanBoard.tsx is only referenced by ProjectDetail.tsx, so it gets deleted.


Conventions

  • Each task ends with a single commit. Commit messages: feat: … for new features, chore: … for housekeeping, refactor: … for in-place restructure, fix: … for bugfixes.
  • Lint runs from the adiuvAI/ submodule: cd adiuvAI && npm run lint.
  • Dev server: cd adiuvAI && source ~/.nvm/nvm.sh && npm start on macOS/Linux, cd adiuvAI; npm start on Windows.
  • git add lists explicit files (no git add -A).
  • Path alias @/* resolves to src/renderer/*.

Phase A — Schema & backend

Task 1: Add estimate column and taskAttachments table to schema

Files:

  • Modify: adiuvAI/src/main/db/schema.ts

Steps:

  • Step 1.1: Add estimate to the tasks table definition (after dueDate):
export const tasks = sqliteTable('tasks', {
  id: text('id').primaryKey(),
  projectId: text('project_id'),
  title: text('title').notNull(),
  description: text('description'),
  status: text('status').notNull().default('todo'),
  priority: text('priority').notNull().default('medium'),
  assignee: text('assignee'),
  dueDate: integer('due_date', { mode: 'number' }),
  estimate: integer('estimate', { mode: 'number' }),  // minutes, nullable
  isAiSuggested: integer('is_ai_suggested', { mode: 'number' }).notNull().default(0),
  createdAt: integer('created_at', { mode: 'number' }).notNull(),
  completedAt: integer('completed_at', { mode: 'number' }),
});
  • Step 1.2: Append the taskAttachments table definition after taskComments:
export const taskAttachments = sqliteTable('task_attachments', {
  id: text('id').primaryKey(),
  taskId: text('task_id').notNull(),
  filename: text('filename').notNull(),
  mimeType: text('mime_type'),
  sizeBytes: integer('size_bytes', { mode: 'number' }).notNull(),
  storedPath: text('stored_path').notNull(),  // relative to userData/attachments
  createdAt: integer('created_at', { mode: 'number' }).notNull(),
});

export type TaskAttachment = InferSelectModel<typeof taskAttachments>;
export type NewTaskAttachment = InferInsertModel<typeof taskAttachments>;
  • Step 1.3: Generate the migration:
cd adiuvAI
npx drizzle-kit generate

Expected: a new file under src/main/db/migrations/ containing ALTER TABLE tasks ADD COLUMN estimate and CREATE TABLE task_attachments.

  • Step 1.4: Apply the migration locally to verify:
npx drizzle-kit push
  • Step 1.5: Run lint:
npm run lint

Expected: passes.

  • Step 1.6: Commit:
git add src/main/db/schema.ts src/main/db/migrations/
git commit -m "feat: add tasks.estimate column and task_attachments table"

Lessons learned: (filled during execution)


Task 2: Attachments storage helper module

Files:

  • Create: adiuvAI/src/main/attachments/storage.ts

Steps:

  • Step 2.1: Create src/main/attachments/storage.ts:
import { app } from 'electron';
import { promises as fs } from 'node:fs';
import path from 'node:path';
import { randomUUID } from 'node:crypto';

const FILENAME_MAX = 200;

function sanitizeFilename(name: string): string {
  const stripped = name
    .replace(/[\\/]/g, '_')
    .replace(/[-]/g, '')
    .replace(/^\.+/, '');
  return stripped.length > FILENAME_MAX ? stripped.slice(0, FILENAME_MAX) : stripped;
}

export function attachmentsRoot(): string {
  return path.join(app.getPath('userData'), 'attachments');
}

export function absolutePath(storedPath: string): string {
  return path.join(attachmentsRoot(), storedPath);
}

export async function copyIntoTask(
  taskId: string,
  sourcePath: string,
  filename: string,
): Promise<{ storedPath: string }> {
  const safeName = sanitizeFilename(filename);
  const dir = path.join(attachmentsRoot(), taskId);
  await fs.mkdir(dir, { recursive: true });
  const finalName = `${randomUUID()}-${safeName}`;
  const dest = path.join(dir, finalName);
  await fs.copyFile(sourcePath, dest);
  return { storedPath: path.join(taskId, finalName) };
}

export async function deleteStored(storedPath: string): Promise<void> {
  const abs = absolutePath(storedPath);
  await fs.unlink(abs).catch((err) => {
    if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
  });
}

export async function deleteTaskDir(taskId: string): Promise<void> {
  const dir = path.join(attachmentsRoot(), taskId);
  await fs.rm(dir, { recursive: true, force: true });
}
  • Step 2.2: Run lint:
npm run lint

Expected: passes.

  • Step 2.3: Commit:
git add src/main/attachments/storage.ts
git commit -m "feat: add attachments storage helper module"

Lessons learned: (filled during execution)


Task 3: taskAttachments tRPC sub-router

Files:

  • Modify: adiuvAI/src/main/router/index.ts

Steps:

  • Step 3.1: At the top of router/index.ts, add imports for the new dependencies:
import { dialog, shell } from 'electron';
import { randomUUID } from 'node:crypto';
import { taskAttachments } from '../db/schema';
import {
  copyIntoTask,
  deleteStored,
  absolutePath,
  deleteTaskDir,
} from '../attachments/storage';
  • Step 3.2: Define the new sub-router. Add this block above the appRouter declaration:
const taskAttachmentsRouter = t.router({
  list: t.procedure
    .input(z.object({ taskId: z.string() }))
    .query(async ({ input }) => {
      const db = getDb();
      return db
        .select()
        .from(taskAttachments)
        .where(eq(taskAttachments.taskId, input.taskId))
        .orderBy(taskAttachments.createdAt);
    }),

  pick: t.procedure.mutation(async () => {
    const result = await dialog.showOpenDialog({
      properties: ['openFile', 'multiSelections'],
    });
    if (result.canceled) return [];
    const { stat } = await import('node:fs/promises');
    const out: Array<{ path: string; name: string; size: number }> = [];
    for (const p of result.filePaths) {
      const s = await stat(p);
      out.push({ path: p, name: p.split(/[\\/]/).pop() ?? p, size: s.size });
    }
    return out;
  }),

  create: t.procedure
    .input(z.object({
      taskId: z.string(),
      sourcePath: z.string(),
      filename: z.string(),
      sizeBytes: z.number().int().nonnegative(),
      mimeType: z.string().optional(),
    }))
    .mutation(async ({ input }) => {
      const db = getDb();
      const { storedPath } = await copyIntoTask(input.taskId, input.sourcePath, input.filename);
      const row = {
        id: randomUUID(),
        taskId: input.taskId,
        filename: input.filename,
        mimeType: input.mimeType ?? null,
        sizeBytes: input.sizeBytes,
        storedPath,
        createdAt: Date.now(),
      };
      await db.insert(taskAttachments).values(row);
      return row;
    }),

  delete: t.procedure
    .input(z.object({ id: z.string() }))
    .mutation(async ({ input }) => {
      const db = getDb();
      const [row] = await db
        .select()
        .from(taskAttachments)
        .where(eq(taskAttachments.id, input.id));
      if (!row) return { ok: false };
      await deleteStored(row.storedPath);
      await db.delete(taskAttachments).where(eq(taskAttachments.id, input.id));
      return { ok: true };
    }),

  open: t.procedure
    .input(z.object({ id: z.string() }))
    .mutation(async ({ input }) => {
      const db = getDb();
      const [row] = await db
        .select()
        .from(taskAttachments)
        .where(eq(taskAttachments.id, input.id));
      if (!row) return { ok: false };
      const err = await shell.openPath(absolutePath(row.storedPath));
      return { ok: err === '' };
    }),
});
  • Step 3.3: Add taskAttachments: taskAttachmentsRouter, to the appRouter object (alphabetically or after taskComments).

  • Step 3.4: Re-export the helper for cascade use in tasks router. Confirm deleteTaskDir is imported (Step 3.1) for use in Task 4.

  • Step 3.5: Run lint:

npm run lint

Expected: passes. If the existing router file uses different import paths or t is named differently (e.g. router), adapt the snippet to match.

  • Step 3.6: Commit:
git add src/main/router/index.ts
git commit -m "feat: add taskAttachments tRPC sub-router (list/pick/create/delete/open)"

Lessons learned: (filled during execution)


Task 4: Tasks router updates — estimate field + cascade attachment delete

Files:

  • Modify: adiuvAI/src/main/router/index.ts

Steps:

  • Step 4.1: Locate the tasks.update procedure. Add estimate to the input schema and to the set(...) payload:
update: t.procedure
  .input(z.object({
    id: z.string(),
    // ...existing fields...
    estimate: z.number().int().nullable().optional(),
  }))
  .mutation(async ({ input }) => {
    const { id, ...rest } = input;
    const db = getDb();
    await db.update(tasks).set(rest).where(eq(tasks.id, id));
    return { ok: true };
  }),

(Adapt to the existing shape of tasks.update. If set(rest) already destructures, add the field once to the input schema.)

  • Step 4.2: Locate the tasks.create procedure. Add estimate to its input schema (same shape as update).

  • Step 4.3: Locate tasks.delete. Before the row deletion, enumerate and delete attachments:

delete: t.procedure
  .input(z.object({ id: z.string() }))
  .mutation(async ({ input }) => {
    const db = getDb();

    // Cascade: delete attachment files + rows for this task
    const atts = await db
      .select()
      .from(taskAttachments)
      .where(eq(taskAttachments.taskId, input.id));
    for (const a of atts) {
      await deleteStored(a.storedPath);
    }
    await db.delete(taskAttachments).where(eq(taskAttachments.taskId, input.id));
    await deleteTaskDir(input.id);

    // existing delete logic for the task row + comments
    await db.delete(tasks).where(eq(tasks.id, input.id));
    return { ok: true };
  }),
  • Step 4.4: Run lint:
npm run lint
  • Step 4.5: Smoke test — start the app, open the existing detail dialog on a task, edit it (no changes), save. Then delete a task. Both must succeed without error.
npm start
  • Step 4.6: Commit:
git add src/main/router/index.ts
git commit -m "feat: tasks.update accepts estimate; tasks.delete cascades attachments"

Lessons learned: (filled during execution)


Phase B — Shared building-block components

Task 5: AssigneeStack component

Files:

  • Create: adiuvAI/src/renderer/components/tasks/AssigneeStack.tsx

Steps:

  • Step 5.1: Create the file:
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { cn } from '@/lib/utils';

function initials(name: string): string {
  return name
    .split(/\s+/)
    .slice(0, 2)
    .map((w) => w[0]?.toUpperCase() ?? '')
    .join('');
}

export function AssigneeStack({
  assignees,
  className,
}: {
  assignees: string[];
  className?: string;
}) {
  if (assignees.length === 0) {
    return <span className="text-muted-foreground text-sm"></span>;
  }
  const visible = assignees.slice(0, 2);
  const overflow = assignees.length - visible.length;
  return (
    <Tooltip>
      <TooltipTrigger asChild>
        <div className={cn('flex items-center', className)}>
          {visible.map((name, i) => (
            <span
              key={name}
              className={cn(
                'flex h-6 w-6 items-center justify-center rounded-full bg-muted text-[10px] font-medium ring-2 ring-background',
                i > 0 && '-ml-2',
              )}
            >
              {initials(name)}
            </span>
          ))}
          {overflow > 0 && (
            <span className="-ml-2 flex h-6 min-w-6 items-center justify-center rounded-full bg-muted px-1.5 text-[10px] font-medium ring-2 ring-background">
              +{overflow}
            </span>
          )}
        </div>
      </TooltipTrigger>
      <TooltipContent>{assignees.join(', ')}</TooltipContent>
    </Tooltip>
  );
}
  • Step 5.2: Lint:
npm run lint
  • Step 5.3: Commit:
git add src/renderer/components/tasks/AssigneeStack.tsx
git commit -m "feat: add AssigneeStack component"

Lessons learned: (filled during execution)


Task 6: StatusBadge component

Files:

  • Create: adiuvAI/src/renderer/components/tasks/StatusBadge.tsx

Steps:

  • Step 6.1: Create the file:
import { useTranslation } from 'react-i18next';
import { Circle, Clock, CheckCircle2 } from 'lucide-react';
import { cn } from '@/lib/utils';

const STATUS_CONFIG = {
  todo: {
    icon: Circle,
    className: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300',
    labelKey: 'tasks.toDo',
  },
  in_progress: {
    icon: Clock,
    className: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300',
    labelKey: 'tasks.inProgress',
  },
  done: {
    icon: CheckCircle2,
    className: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300',
    labelKey: 'tasks.done',
  },
} as const;

export function StatusBadge({ status, className }: { status: string | null; className?: string }) {
  const { t } = useTranslation();
  const conf = STATUS_CONFIG[(status ?? 'todo') as keyof typeof STATUS_CONFIG] ?? STATUS_CONFIG.todo;
  const Icon = conf.icon;
  return (
    <span
      className={cn(
        'inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-medium',
        conf.className,
        className,
      )}
    >
      <Icon className="h-3 w-3" />
      {t(conf.labelKey)}
    </span>
  );
}
  • Step 6.2: Lint, commit:
npm run lint
git add src/renderer/components/tasks/StatusBadge.tsx
git commit -m "feat: add StatusBadge component"

Lessons learned: (filled during execution)


Task 7: TaskAttachmentChip component + add-file flow hook

Files:

  • Create: adiuvAI/src/renderer/components/tasks/TaskAttachmentChip.tsx
  • Create: adiuvAI/src/renderer/components/tasks/useTaskAttachments.ts

Steps:

  • Step 7.1: Create the chip:
import { Paperclip, X } from 'lucide-react';
import { cn } from '@/lib/utils';

function formatSize(bytes: number): string {
  if (bytes < 1024) return `${bytes} B`;
  if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}

export function TaskAttachmentChip({
  filename,
  sizeBytes,
  onOpen,
  onDelete,
}: {
  filename: string;
  sizeBytes: number;
  onOpen: () => void;
  onDelete: () => void;
}) {
  return (
    <span
      className={cn(
        'inline-flex items-center gap-1.5 rounded-full border border-border/60 bg-background/60 px-2.5 py-1 text-xs',
      )}
    >
      <button type="button" onClick={onOpen} className="flex items-center gap-1.5 hover:underline">
        <Paperclip className="h-3 w-3" />
        <span className="max-w-[180px] truncate">{filename}</span>
        <span className="text-muted-foreground">· {formatSize(sizeBytes)}</span>
      </button>
      <button type="button" onClick={onDelete} className="text-muted-foreground hover:text-destructive">
        <X className="h-3 w-3" />
      </button>
    </span>
  );
}
  • Step 7.2: Create the hook (encapsulates pick → create flow + 50 MB cap):
import { useTranslation } from 'react-i18next';
import { trpc } from '@/lib/trpc';
import { useNotify } from '@/hooks/useNotify';

const MAX_SIZE = 50 * 1024 * 1024;

export function useTaskAttachments(taskId: string | null) {
  const { t } = useTranslation();
  const utils = trpc.useUtils();
  const { notify, notifyError } = useNotify();

  const list = trpc.taskAttachments.list.useQuery(
    { taskId: taskId ?? '' },
    { enabled: !!taskId },
  );
  const pick = trpc.taskAttachments.pick.useMutation();
  const create = trpc.taskAttachments.create.useMutation({
    onSuccess: () => taskId && void utils.taskAttachments.list.invalidate({ taskId }),
    onError: (err) => notifyError('toast.attachment.createError', err),
  });
  const remove = trpc.taskAttachments.delete.useMutation({
    onSuccess: () => taskId && void utils.taskAttachments.list.invalidate({ taskId }),
  });
  const open = trpc.taskAttachments.open.useMutation();

  async function addFiles() {
    if (!taskId) return;
    const picked = await pick.mutateAsync();
    for (const f of picked) {
      if (f.size > MAX_SIZE) {
        notify('warning', 'toast.attachment.tooLarge', { filename: f.name });
        continue;
      }
      await create.mutateAsync({
        taskId,
        sourcePath: f.path,
        filename: f.name,
        sizeBytes: f.size,
      });
    }
  }

  return { list, addFiles, remove, open };
}
  • Step 7.3: Lint, commit:
npm run lint
git add src/renderer/components/tasks/TaskAttachmentChip.tsx src/renderer/components/tasks/useTaskAttachments.ts
git commit -m "feat: add TaskAttachmentChip + useTaskAttachments hook"

Lessons learned: (filled during execution)


Task 8: ChatInputBox — add 'comment' variant

Files:

  • Modify: adiuvAI/src/renderer/components/ai/ChatInputBox.tsx

Steps:

  • Step 8.1: Open the file. Locate the VARIANT_STYLES object. Extend the type alias at the top:
type ChatInputBoxVariant = 'panel' | 'floating' | 'comment';
  • Step 8.2: Add a comment entry to VARIANT_STYLES:
comment: {
  container: 'flex items-end gap-2 px-3 py-2',
  textarea: 'flex-1 resize-none bg-transparent text-sm placeholder:text-muted-foreground outline-none max-h-32 overflow-y-auto',
  button: 'flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-primary text-primary-foreground shadow-sm transition-all hover:bg-primary/90 active:scale-95 disabled:opacity-30 disabled:cursor-not-allowed',
  iconSize: 14,
},
  • Step 8.3: Verify the existing VARIANT_STYLES[variant] lookup still type-checks (it should since the new variant follows the same shape). Save.

  • Step 8.4: Lint, commit:

npm run lint
git add src/renderer/components/ai/ChatInputBox.tsx
git commit -m "feat: add 'comment' variant to ChatInputBox"

Lessons learned: (filled during execution)


Phase C — Detail sheet

Task 9: TaskDetailSheet skeleton with sticky regions

Files:

  • Create: adiuvAI/src/renderer/components/tasks/TaskDetailSheet.tsx

Steps:

  • Step 9.1: Create the file with the three-region structure (header, body, composer) but stub the inner content:
import { Sheet, SheetContent } from '@/components/ui/sheet';
import { type TaskItem } from './TaskRow';

interface Props {
  task: TaskItem | null;
  open: boolean;
  onOpenChange: (open: boolean) => void;
  onEdit: (task: TaskItem) => void;
  onDelete: (id: string) => void;
}

export function TaskDetailSheet({ task, open, onOpenChange, onEdit, onDelete }: Props) {
  if (!task) return null;
  return (
    <Sheet open={open} onOpenChange={onOpenChange}>
      <SheetContent
        side="right"
        className="w-[480px] !max-w-[480px] flex flex-col p-0 gap-0 bg-card/85 backdrop-blur-xl border-border/50"
      >
        {/* Sticky header */}
        <div className="px-6 pt-6 pb-4 border-b border-border/40 shrink-0">
          <div className="text-xs text-muted-foreground">{task.clientName ?? '—'}  {task.projectName ?? '—'}</div>
          <div className="text-lg font-semibold leading-tight mt-1">{task.title}</div>
        </div>

        {/* Scrolling body */}
        <div className="flex-1 overflow-y-auto">
          {/* Properties + description + comments go here in later tasks */}
        </div>

        {/* Sticky composer */}
        <div className="px-6 py-3 border-t border-border/40 shrink-0">
          {/* Composer wired in Task 13 */}
        </div>
      </SheetContent>
    </Sheet>
  );
}
  • Step 9.2: Lint, commit:
npm run lint
git add src/renderer/components/tasks/TaskDetailSheet.tsx
git commit -m "feat: add TaskDetailSheet skeleton (sticky header/body/composer)"

Lessons learned: (filled during execution)


Task 10: Wire header — breadcrumb, title, priority/status chips, overflow menu

Files:

  • Modify: adiuvAI/src/renderer/components/tasks/TaskDetailSheet.tsx

Steps:

  • Step 10.1: Replace the imports + header block. Add at the top of the file:
import { useTranslation } from 'react-i18next';
import { MoreHorizontal, Pencil, Trash2, ChevronRight } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { PriorityBadge } from './PriorityBadge';
import { StatusBadge } from './StatusBadge';
  • Step 10.2: Replace the header <div> body:
<div className="px-6 pt-6 pb-4 border-b border-border/40 shrink-0">
  <div className="flex items-start justify-between gap-2">
    <div className="text-xs text-muted-foreground flex items-center gap-1 min-w-0">
      {task.clientName && <span className="truncate">{task.clientName}</span>}
      {task.clientName && task.projectName && <ChevronRight className="h-3 w-3 shrink-0" />}
      {task.projectName && <span className="text-foreground font-medium truncate">{task.projectName}</span>}
    </div>
    <DropdownMenu>
      <DropdownMenuTrigger asChild>
        <Button variant="ghost" size="icon" className="h-7 w-7 -mr-1">
          <MoreHorizontal className="h-4 w-4" />
        </Button>
      </DropdownMenuTrigger>
      <DropdownMenuContent align="end">
        <DropdownMenuItem onSelect={() => onEdit(task)}>
          <Pencil className="h-4 w-4 mr-2" />
          {t('common.edit')}
        </DropdownMenuItem>
        <DropdownMenuItem onSelect={() => onDelete(task.id)} className="text-destructive focus:text-destructive">
          <Trash2 className="h-4 w-4 mr-2" />
          {t('common.delete')}
        </DropdownMenuItem>
      </DropdownMenuContent>
    </DropdownMenu>
  </div>
  <div className="text-lg font-semibold leading-tight mt-1">{task.title}</div>
  <div className="flex items-center gap-2 mt-2">
    <PriorityBadge priority={task.priority} />
    <StatusBadge status={task.status} />
  </div>
</div>
  • Step 10.3: Add const { t } = useTranslation(); near the top of the component body.

  • Step 10.4: Lint, commit:

npm run lint
git add src/renderer/components/tasks/TaskDetailSheet.tsx
git commit -m "feat: TaskDetailSheet header — breadcrumb, title, chips, overflow menu"

Lessons learned: (filled during execution)


Task 11: Properties card — assignee, due, estimate, created

Files:

  • Modify: adiuvAI/src/renderer/components/tasks/TaskDetailSheet.tsx

Steps:

  • Step 11.1: Add imports:
import { useFormatPrefs, formatDueDate, formatRelative } from '@/lib/date';
import { parseAssignees } from './task-utils';
import { AssigneeStack } from './AssigneeStack';
  • Step 11.2: Inside the body div, render the properties card as the first child:
<div className="mx-6 mt-4 rounded-lg border border-border/40 bg-background/40 p-4">
  <div className="grid grid-cols-2 gap-x-6 gap-y-4">
    <PropRow label={t('tasks.assignee')}>
      <AssigneeStack assignees={parseAssignees(task.assignee)} />
    </PropRow>
    <PropRow label={t('tasks.due')}>
      {task.dueDate ? formatDueDate(task.dueDate, prefs) : <span className="text-muted-foreground"></span>}
    </PropRow>
    <PropRow label={t('tasks.estimate')}>
      <span className="text-muted-foreground"></span>
    </PropRow>
    <PropRow label={t('tasks.created')}>
      {formatRelative(task.createdAt ?? Date.now())}
    </PropRow>
  </div>
</div>
  • Step 11.3: Add the PropRow helper at the bottom of the file (above default export, or below the main component):
function PropRow({ label, children }: { label: string; children: React.ReactNode }) {
  return (
    <div>
      <div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-1">{label}</div>
      <div className="text-sm">{children}</div>
    </div>
  );
}
  • Step 11.4: Add const prefs = useFormatPrefs(); inside the component body alongside t.

  • Step 11.5: TaskItem (TaskRow.tsx) currently does not include createdAt. Open TaskRow.tsx and add it to the TaskItem type:

export type TaskItem = {
  id: string;
  projectId: string | null;
  title: string;
  description: string | null;
  status: string | null;
  priority: string | null;
  assignee: string | null;
  dueDate: number | null;
  createdAt: number | null;
  estimate: number | null;
  isAiSuggested: number;
  projectName: string | null;
  clientName: string | null;
  subClientName: string | null;
};

Verify the tasks.list tRPC procedure already returns createdAt and (after Task 4) estimate. If missing, add them to the select(...) projection in the list procedure.

  • Step 11.6: Lint, commit:
npm run lint
git add src/renderer/components/tasks/TaskDetailSheet.tsx src/renderer/components/tasks/TaskRow.tsx src/main/router/index.ts
git commit -m "feat: TaskDetailSheet properties card (assignee/due/estimate/created)"

Lessons learned: (filled during execution)


Task 12: Attachments inline strip in properties card

Files:

  • Modify: adiuvAI/src/renderer/components/tasks/TaskDetailSheet.tsx

Steps:

  • Step 12.1: Add imports:
import { Plus } from 'lucide-react';
import { TaskAttachmentChip } from './TaskAttachmentChip';
import { useTaskAttachments } from './useTaskAttachments';
  • Step 12.2: Inside the component body, wire the hook:
const attachments = useTaskAttachments(task.id);
  • Step 12.3: After the existing 4 <PropRow> cells, add a full-width files row inside the same grid (use a col-span-2 container):
<div className="col-span-2">
  <div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-2">
    {t('tasks.attachments')}
  </div>
  <div className="flex flex-wrap items-center gap-2">
    {(attachments.list.data ?? []).map((a) => (
      <TaskAttachmentChip
        key={a.id}
        filename={a.filename}
        sizeBytes={a.sizeBytes}
        onOpen={() => attachments.open.mutate({ id: a.id })}
        onDelete={() => attachments.remove.mutate({ id: a.id })}
      />
    ))}
    <button
      type="button"
      onClick={() => attachments.addFiles()}
      className="inline-flex items-center gap-1 rounded-full border border-dashed border-border px-2.5 py-1 text-xs text-muted-foreground hover:text-foreground hover:border-foreground/50"
    >
      <Plus className="h-3 w-3" />
      {t('tasks.addFile')}
    </button>
  </div>
</div>
  • Step 12.4: Lint:
npm run lint
  • Step 12.5: Smoke test: open existing app via npm start, open the Sheet on a task (you'll need to wire it to an existing trigger temporarily — or wait for Task 15. Skip the smoke test if not yet wired).

  • Step 12.6: Commit:

git add src/renderer/components/tasks/TaskDetailSheet.tsx
git commit -m "feat: TaskDetailSheet attachments inline strip with add-file flow"

Lessons learned: (filled during execution)


Task 13: Description, comments, sticky composer

Files:

  • Modify: adiuvAI/src/renderer/components/tasks/TaskDetailSheet.tsx

Steps:

  • Step 13.1: Add imports:
import { useState } from 'react';
import { trpc } from '@/lib/trpc';
import { useNotify } from '@/hooks/useNotify';
import { ChatInputBox } from '@/components/ai/ChatInputBox';
  • Step 13.2: Inside the component body:
const utils = trpc.useUtils();
const { notify, notifyError } = useNotify();
const { data: comments } = trpc.taskComments.list.useQuery(
  { taskId: task.id },
  { enabled: !!task },
);
const addComment = trpc.taskComments.create.useMutation({
  onSuccess: () => {
    notify('success', 'toast.comment.created');
    void utils.taskComments.list.invalidate({ taskId: task.id });
  },
  onError: (err) => notifyError('toast.comment.createError', err),
});
const deleteComment = trpc.taskComments.delete.useMutation({
  onSuccess: () => void utils.taskComments.list.invalidate({ taskId: task.id }),
});
  • Step 13.3: Inside the scrolling body (after the properties card), add description + comments:
<div className="px-6 py-4">
  <div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-2">
    {t('tasks.description')}
  </div>
  {task.description ? (
    <div className="text-sm whitespace-pre-wrap">{task.description}</div>
  ) : (
    <div className="text-sm italic text-muted-foreground">{t('tasks.noDescription')}</div>
  )}
</div>

<div className="h-px bg-border/40 mx-6" />

<div className="px-6 py-4">
  <div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-2">
    {t('tasks.comments')} · {comments?.length ?? 0}
  </div>
  <div className="flex flex-col gap-3">
    {(comments ?? []).map((c) => (
      <div key={c.id} className="flex gap-3">
        <div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-muted text-[10px] font-medium">
          {c.author.split(/\s+/).slice(0, 2).map((w) => w[0]?.toUpperCase() ?? '').join('')}
        </div>
        <div className="flex-1 min-w-0">
          <div className="flex items-center justify-between mb-1">
            <span className="text-xs font-medium">{c.author}</span>
            <span className="text-[10px] text-muted-foreground">{formatRelative(c.createdAt)}</span>
          </div>
          <div className="rounded-md bg-background/60 px-3 py-2 text-sm">{c.content}</div>
          <button
            type="button"
            onClick={() => deleteComment.mutate({ id: c.id })}
            className="mt-1 text-[10px] text-muted-foreground hover:text-destructive"
          >
            {t('common.delete')}
          </button>
        </div>
      </div>
    ))}
  </div>
</div>
  • Step 13.4: Replace the sticky composer footer:
<div className="px-6 py-3 border-t border-border/40 shrink-0">
  <div className="rounded-2xl bg-background/70 backdrop-blur-xl border border-border/50 shadow-lg ring-1 ring-border/20 transition-shadow focus-within:shadow-xl focus-within:border-ring/50">
    <ChatInputBox
      cacheKey={`task-comment-${task.id}`}
      isStreaming={false}
      variant="comment"
      placeholder={t('tasks.writeComment')}
      onSend={(text) => addComment.mutate({ taskId: task.id, author: 'Me', content: text })}
    />
  </div>
</div>
  • Step 13.5: Lint:
npm run lint
  • Step 13.6: Commit:
git add src/renderer/components/tasks/TaskDetailSheet.tsx
git commit -m "feat: TaskDetailSheet description, comments, and ChatInputBox composer"

Lessons learned: (filled during execution)


Task 14: Wire TaskDetailSheet into Tasks page; delete TaskDetailDialog

Files:

  • Modify: adiuvAI/src/renderer/routes/tasks.tsx
  • Delete: adiuvAI/src/renderer/components/tasks/TaskDetailDialog.tsx

Steps:

  • Step 14.1: In routes/tasks.tsx, replace the TaskDetailDialog import and usage with TaskDetailSheet:
import { TaskDetailSheet } from '@/components/tasks/TaskDetailSheet';
// ...
<TaskDetailSheet
  task={viewTask}
  open={!!viewTask}
  onOpenChange={(open) => { if (!open) setViewTask(null); }}
  onEdit={(task) => { setViewTask(null); setEditTask(task); }}
  onDelete={(id) => { deleteTask.mutate({ id }); setViewTask(null); }}
/>
  • Step 14.2: Delete the old dialog file:
git rm src/renderer/components/tasks/TaskDetailDialog.tsx
  • Step 14.3: Search for any other references to TaskDetailDialog in the codebase and remove them:

Run: grep -rn "TaskDetailDialog" src/. Expect: no matches.

  • Step 14.4: Smoke test in dev:
npm start

Click a task in the existing list — the right-side sheet should open. Add an attachment, write a comment, change priority/status (popovers wired in next task), close the sheet. Verify Edit and Delete in the overflow menu.

  • Step 14.5: Lint, commit:
npm run lint
git add src/renderer/routes/tasks.tsx src/renderer/components/tasks/TaskDetailDialog.tsx
git commit -m "refactor: replace TaskDetailDialog with TaskDetailSheet"

Lessons learned: (filled during execution)


Task 15: Header chips become clickable popovers (priority + status)

Files:

  • Modify: adiuvAI/src/renderer/components/tasks/TaskDetailSheet.tsx

Steps:

  • Step 15.1: Add imports:
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';

Also import the tasks.update mutation:

const updateTask = trpc.tasks.update.useMutation({
  onSuccess: () => void utils.tasks.list.invalidate(),
  onError: (err) => notifyError('toast.task.updateError', err),
});
  • Step 15.2: Wrap the <PriorityBadge> chip in a Popover:
<Popover>
  <PopoverTrigger asChild>
    <button type="button" className="rounded hover:bg-accent/50 px-1 -mx-1">
      <PriorityBadge priority={task.priority} />
    </button>
  </PopoverTrigger>
  <PopoverContent className="w-40 p-1" align="start">
    {(['high', 'medium', 'low'] as const).map((p) => (
      <button
        key={p}
        type="button"
        onClick={() => updateTask.mutate({ id: task.id, priority: p })}
        className="w-full text-left px-2 py-1.5 text-sm rounded hover:bg-accent/50"
      >
        {t(`tasks.${p}`)}
      </button>
    ))}
  </PopoverContent>
</Popover>
  • Step 15.3: Same pattern for <StatusBadge> with todo / in_progress / done:
<Popover>
  <PopoverTrigger asChild>
    <button type="button" className="rounded hover:bg-accent/50 px-1 -mx-1">
      <StatusBadge status={task.status} />
    </button>
  </PopoverTrigger>
  <PopoverContent className="w-40 p-1" align="start">
    {(['todo', 'in_progress', 'done'] as const).map((s) => (
      <button
        key={s}
        type="button"
        onClick={() => updateTask.mutate({ id: task.id, status: s })}
        className="w-full text-left px-2 py-1.5 text-sm rounded hover:bg-accent/50"
      >
        {t(`tasks.${s === 'todo' ? 'toDo' : s === 'in_progress' ? 'inProgress' : 'done'}`)}
      </button>
    ))}
  </PopoverContent>
</Popover>
  • Step 15.4: Lint, commit:
npm run lint
git add src/renderer/components/tasks/TaskDetailSheet.tsx
git commit -m "feat: TaskDetailSheet — clickable priority/status chips"

Lessons learned: (filled during execution)


Phase D — Form dialog

Task 16: TaskFormDialog shell with title + description

Files:

  • Create: adiuvAI/src/renderer/components/tasks/TaskFormDialog.tsx

Steps:

  • Step 16.1: Create the file with the dialog frame, title input, description textarea, and a footer:
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import {
  Dialog,
  DialogContent,
  DialogHeader,
  DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';

export type TaskFormValues = {
  title: string;
  description: string;
  priority: string;
  status: string;
  dueDate: number | null;
  projectId: string | null;
  assignees: string[];
  estimate: number | null;
};

interface Props {
  open: boolean;
  onOpenChange: (open: boolean) => void;
  mode: 'create' | 'edit';
  taskId?: string;                   // required when mode='edit' for attachments
  initialValues?: Partial<TaskFormValues>;
  onSubmit: (values: TaskFormValues) => void;
  isSubmitting?: boolean;
}

const DEFAULTS: TaskFormValues = {
  title: '',
  description: '',
  priority: 'medium',
  status: 'todo',
  dueDate: null,
  projectId: null,
  assignees: [],
  estimate: null,
};

export function TaskFormDialog({
  open,
  onOpenChange,
  mode,
  taskId,
  initialValues,
  onSubmit,
  isSubmitting,
}: Props) {
  const { t } = useTranslation();
  const [values, setValues] = useState<TaskFormValues>({ ...DEFAULTS, ...initialValues });

  useEffect(() => {
    if (open) setValues({ ...DEFAULTS, ...initialValues });
  }, [open, initialValues]);

  function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    if (!values.title.trim()) return;
    onSubmit({ ...values, title: values.title.trim() });
  }

  return (
    <Dialog open={open} onOpenChange={onOpenChange}>
      <DialogContent className="sm:max-w-[580px] p-0 gap-0 overflow-hidden bg-card/92 backdrop-blur-xl">
        <DialogHeader className="px-5 py-3 border-b border-border/40 flex-row justify-between items-center">
          <DialogTitle className="text-sm font-medium">
            {mode === 'create' ? t('tasks.newTask') : t('tasks.editTask')}
          </DialogTitle>
          <span className="text-[11px] text-muted-foreground">+Enter</span>
        </DialogHeader>

        <form onSubmit={handleSubmit} onKeyDown={(e) => {
          if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') handleSubmit(e);
        }}>
          <div className="px-5 pt-5 pb-2">
            <input
              autoFocus
              className="w-full bg-transparent border-none outline-none text-[22px] font-medium leading-tight placeholder:text-muted-foreground/60"
              placeholder={t('tasks.whatNeedsToBeDone')}
              value={values.title}
              onChange={(e) => setValues((v) => ({ ...v, title: e.target.value }))}
            />
            <textarea
              className="mt-2 w-full bg-transparent border-none outline-none text-sm resize-none placeholder:text-muted-foreground/60"
              rows={3}
              placeholder={t('tasks.descriptionOptional')}
              value={values.description}
              onChange={(e) => setValues((v) => ({ ...v, description: e.target.value }))}
            />
          </div>

          {/* PROPERTIES section + footer go in next tasks */}
          <div className="px-5 pb-3 pt-1">
            <div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-2">
              {t('tasks.properties')}
            </div>
            <div className="flex flex-wrap gap-1.5" data-testid="property-pills">
              {/* pills wired in Task 17 */}
            </div>
          </div>

          <div className="flex items-center justify-end gap-2 px-5 py-3 border-t border-border/40 bg-background/30">
            <Button type="button" variant="outline" size="sm" onClick={() => onOpenChange(false)}>
              {t('common.cancel')}
            </Button>
            <Button type="submit" size="sm" disabled={!values.title.trim() || isSubmitting}>
              {isSubmitting
                ? t('common.saving')
                : mode === 'create'
                ? t('tasks.createTask')
                : t('common.save')}
            </Button>
          </div>
        </form>
      </DialogContent>
    </Dialog>
  );
}

taskId is unused in this task — silenced with an underscore prefix in the destructure if lint complains, or left in the signature for the next task to reference.

  • Step 16.2: Lint, commit:
npm run lint
git add src/renderer/components/tasks/TaskFormDialog.tsx
git commit -m "feat: TaskFormDialog shell with title/description"

Lessons learned: (filled during execution)


Task 17: Property pills (project / priority / status / due / assignees)

Files:

  • Create: adiuvAI/src/renderer/components/tasks/PropertyPill.tsx
  • Modify: adiuvAI/src/renderer/components/tasks/TaskFormDialog.tsx

Steps:

  • Step 17.1: Create the generic pill primitive:
import { cn } from '@/lib/utils';

export function PropertyPill({
  icon,
  label,
  value,
  empty,
  children,
  onClick,
}: {
  icon: React.ReactNode;
  label: string;
  value?: React.ReactNode;
  empty?: boolean;
  children?: never;
  onClick?: () => void;
}) {
  return (
    <button
      type="button"
      onClick={onClick}
      className={cn(
        'inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs transition-colors',
        empty
          ? 'border border-dashed border-border text-muted-foreground hover:text-foreground hover:border-foreground/50'
          : 'border border-border/60 bg-background/60 text-foreground hover:border-ring/40',
      )}
    >
      <span className="flex items-center">{icon}</span>
      {empty ? (
        <span>{label}</span>
      ) : (
        <>
          <span className="text-muted-foreground">{label}:</span>
          <span>{value}</span>
        </>
      )}
    </button>
  );
}
  • Step 17.2: In TaskFormDialog.tsx, replace the empty <div data-testid="property-pills"> with five pills. Each pill is wrapped in a Popover from shadcn. Add imports:
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Calendar } from '@/components/ui/calendar';
import { Folder, ArrowUp, ArrowRight, ArrowDown, Circle, Clock, CheckCircle2, Calendar as CalIcon, UserPlus } from 'lucide-react';
import { trpc } from '@/lib/trpc';
import { PropertyPill } from './PropertyPill';
import { useFormatPrefs, formatDate } from '@/lib/date';
  • Step 17.3: Inside the component, fetch project list and known assignees:
const { data: projectsList = [] } = trpc.projects.listAll.useQuery();
const { data: knownAssignees = [] } = trpc.tasks.listAssignees.useQuery();
const prefs = useFormatPrefs();
const selectedProject = projectsList.find((p) => p.id === values.projectId);
  • Step 17.4: Render the five pills inside the property pills container:
{/* Project */}
<Popover>
  <PopoverTrigger asChild>
    <span>
      <PropertyPill
        icon={<Folder className="h-3 w-3" />}
        label={t('tasks.project')}
        value={selectedProject?.name ?? null}
        empty={!selectedProject}
      />
    </span>
  </PopoverTrigger>
  <PopoverContent className="w-64 p-1" align="start">
    <button
      type="button"
      className="w-full text-left px-2 py-1.5 text-sm rounded hover:bg-accent/50"
      onClick={() => setValues((v) => ({ ...v, projectId: null }))}
    >
      {t('tasks.noProject')}
    </button>
    {projectsList.map((p) => (
      <button
        key={p.id}
        type="button"
        className="w-full text-left px-2 py-1.5 text-sm rounded hover:bg-accent/50"
        onClick={() => setValues((v) => ({ ...v, projectId: p.id }))}
      >
        {p.name}
      </button>
    ))}
  </PopoverContent>
</Popover>

{/* Priority */}
<Popover>
  <PopoverTrigger asChild>
    <span>
      <PropertyPill
        icon={
          values.priority === 'high' ? <ArrowUp className="h-3 w-3 text-red-600" /> :
          values.priority === 'low' ? <ArrowDown className="h-3 w-3 text-muted-foreground" /> :
          <ArrowRight className="h-3 w-3 text-amber-600" />
        }
        label={t('tasks.priority')}
        value={t(`tasks.${values.priority}`)}
      />
    </span>
  </PopoverTrigger>
  <PopoverContent className="w-40 p-1" align="start">
    {(['high', 'medium', 'low'] as const).map((p) => (
      <button
        key={p}
        type="button"
        onClick={() => setValues((v) => ({ ...v, priority: p }))}
        className="w-full text-left px-2 py-1.5 text-sm rounded hover:bg-accent/50"
      >
        {t(`tasks.${p}`)}
      </button>
    ))}
  </PopoverContent>
</Popover>

{/* Status */}
<Popover>
  <PopoverTrigger asChild>
    <span>
      <PropertyPill
        icon={
          values.status === 'in_progress' ? <Clock className="h-3 w-3" /> :
          values.status === 'done' ? <CheckCircle2 className="h-3 w-3" /> :
          <Circle className="h-3 w-3" />
        }
        label={t('tasks.status')}
        value={t(values.status === 'todo' ? 'tasks.toDo' : values.status === 'in_progress' ? 'tasks.inProgress' : 'tasks.done')}
      />
    </span>
  </PopoverTrigger>
  <PopoverContent className="w-40 p-1" align="start">
    {(['todo', 'in_progress', 'done'] as const).map((s) => (
      <button
        key={s}
        type="button"
        onClick={() => setValues((v) => ({ ...v, status: s }))}
        className="w-full text-left px-2 py-1.5 text-sm rounded hover:bg-accent/50"
      >
        {t(s === 'todo' ? 'tasks.toDo' : s === 'in_progress' ? 'tasks.inProgress' : 'tasks.done')}
      </button>
    ))}
  </PopoverContent>
</Popover>

{/* Due date */}
<Popover>
  <PopoverTrigger asChild>
    <span>
      <PropertyPill
        icon={<CalIcon className="h-3 w-3" />}
        label={t('tasks.due')}
        value={values.dueDate ? formatDate(values.dueDate, prefs) : null}
        empty={!values.dueDate}
      />
    </span>
  </PopoverTrigger>
  <PopoverContent className="w-auto p-0" align="start">
    <Calendar
      mode="single"
      selected={values.dueDate ? new Date(values.dueDate) : undefined}
      onSelect={(d) => setValues((v) => ({ ...v, dueDate: d?.getTime() ?? null }))}
    />
  </PopoverContent>
</Popover>

{/* Assignees */}
<Popover>
  <PopoverTrigger asChild>
    <span>
      <PropertyPill
        icon={<UserPlus className="h-3 w-3" />}
        label={t('tasks.assignees')}
        value={values.assignees.length > 0 ? values.assignees.join(', ') : null}
        empty={values.assignees.length === 0}
      />
    </span>
  </PopoverTrigger>
  <PopoverContent className="w-64 p-2" align="start">
    <div className="flex flex-col gap-1 max-h-40 overflow-y-auto">
      {knownAssignees.map((name) => (
        <button
          key={name}
          type="button"
          onClick={() => setValues((v) => ({
            ...v,
            assignees: v.assignees.includes(name)
              ? v.assignees.filter((a) => a !== name)
              : [...v.assignees, name],
          }))}
          className="text-left px-2 py-1.5 text-sm rounded hover:bg-accent/50"
        >
          {values.assignees.includes(name) ? '✓ ' : '   '}{name}
        </button>
      ))}
    </div>
  </PopoverContent>
</Popover>

This intentionally omits inline new-project, new-client, and new-assignee creation — see Task 18.

  • Step 17.5: Lint, commit:
npm run lint
git add src/renderer/components/tasks/PropertyPill.tsx src/renderer/components/tasks/TaskFormDialog.tsx
git commit -m "feat: TaskFormDialog property pills with popover editors"

Lessons learned: (filled during execution)


Task 18: Inline create — new project / new client / new assignee inside pill popovers

Files:

  • Modify: adiuvAI/src/renderer/components/tasks/TaskFormDialog.tsx

Steps:

  • Step 18.1: Read the existing inline-creation logic in NewTaskDialog.tsx (lines ~62-186) — creatingProject, creatingClient, creatingSubClient, handleCreateInlineProject. Port that state and handler into TaskFormDialog. The state lives at component top level; the form view is shown inside the Project pill popover when creatingProject is true.

  • Step 18.2: Inside the Project pill <PopoverContent>, replace the simple list with a switcher:

{!creatingProject ? (
  <div className="flex flex-col">
    <button onClick={() => setCreatingProject(true)} className="...">
      <Plus className="h-3 w-3 mr-1" />{t('tasks.newProject')}
    </button>
    <Separator className="my-1" />
    <button onClick={() => setValues((v) => ({ ...v, projectId: null }))}>{t('tasks.noProject')}</button>
    {projectsList.map((p) => /* same as Task 17 */)}
  </div>
) : (
  <InlineProjectForm
    onCancel={() => setCreatingProject(false)}
    onCreated={(projectId) => {
      setValues((v) => ({ ...v, projectId }));
      setCreatingProject(false);
    }}
  />
)}
  • Step 18.3: Implement InlineProjectForm either as a private component in this file or in src/renderer/components/tasks/InlineProjectForm.tsx. Reuse the structure from NewTaskDialog.tsx lines 379-526 (project name + client select w/ inline create + sub-client select w/ inline create). Wire to trpc.clients.create and trpc.projects.create mutations.

  • Step 18.4: In the Assignees pill popover, add a footer input with + Add button to push a new name into values.assignees. Mirror the existing pattern in NewTaskDialog.tsx lines 593-617.

  • Step 18.5: Lint, commit:

npm run lint
git add src/renderer/components/tasks/TaskFormDialog.tsx src/renderer/components/tasks/InlineProjectForm.tsx
git commit -m "feat: inline project/client/assignee creation in TaskFormDialog pills"

Lessons learned: (filled during execution)


Task 19: Wire NewTaskDialog and EditTaskDialog as wrappers

Files:

  • Modify: adiuvAI/src/renderer/components/tasks/NewTaskDialog.tsx
  • Modify: adiuvAI/src/renderer/components/tasks/EditTaskDialog.tsx

Steps:

  • Step 19.1: Replace the entire body of NewTaskDialog.tsx with a thin wrapper:
import { trpc } from '@/lib/trpc';
import { useNotify } from '@/hooks/useNotify';
import { TaskFormDialog, type TaskFormValues } from './TaskFormDialog';

interface Props {
  open: boolean;
  onOpenChange: (open: boolean) => void;
  defaultProjectId?: string;
  defaultStatus?: string;
}

export function NewTaskDialog({ open, onOpenChange, defaultProjectId, defaultStatus }: Props) {
  const utils = trpc.useUtils();
  const { notify, notifyError } = useNotify();
  const create = trpc.tasks.create.useMutation({
    onSuccess: () => {
      notify('success', 'toast.task.created');
      void utils.tasks.list.invalidate();
      onOpenChange(false);
    },
    onError: (err) => notifyError('toast.task.createError', err),
  });

  function handleSubmit(values: TaskFormValues) {
    create.mutate({
      title: values.title,
      description: values.description || undefined,
      priority: values.priority,
      status: values.status,
      dueDate: values.dueDate ?? undefined,
      projectId: values.projectId ?? undefined,
      assignees: values.assignees.length ? values.assignees : undefined,
      estimate: values.estimate ?? undefined,
    });
  }

  return (
    <TaskFormDialog
      open={open}
      onOpenChange={onOpenChange}
      mode="create"
      initialValues={{
        projectId: defaultProjectId ?? null,
        status: defaultStatus ?? 'todo',
      }}
      onSubmit={handleSubmit}
      isSubmitting={create.isPending}
    />
  );
}
  • Step 19.2: Read the existing EditTaskDialog.tsx to understand its props/inputs. Replace its body with a similar wrapper that prefills from the passed task:
import { trpc } from '@/lib/trpc';
import { useNotify } from '@/hooks/useNotify';
import { TaskFormDialog, type TaskFormValues } from './TaskFormDialog';
import { type TaskItem, parseAssignees } from './TaskRow';

interface Props {
  task: TaskItem | null;
  open: boolean;
  onOpenChange: (open: boolean) => void;
}

export function EditTaskDialog({ task, open, onOpenChange }: Props) {
  const utils = trpc.useUtils();
  const { notify, notifyError } = useNotify();
  const update = trpc.tasks.update.useMutation({
    onSuccess: () => {
      notify('success', 'toast.task.updated');
      void utils.tasks.list.invalidate();
      onOpenChange(false);
    },
    onError: (err) => notifyError('toast.task.updateError', err),
  });

  if (!task) return null;

  function handleSubmit(values: TaskFormValues) {
    update.mutate({
      id: task!.id,
      title: values.title,
      description: values.description || null,
      priority: values.priority,
      status: values.status,
      dueDate: values.dueDate,
      projectId: values.projectId,
      assignees: values.assignees,
      estimate: values.estimate,
    });
  }

  return (
    <TaskFormDialog
      open={open}
      onOpenChange={onOpenChange}
      mode="edit"
      taskId={task.id}
      initialValues={{
        title: task.title,
        description: task.description ?? '',
        priority: task.priority ?? 'medium',
        status: task.status ?? 'todo',
        dueDate: task.dueDate ?? null,
        projectId: task.projectId ?? null,
        assignees: parseAssignees(task.assignee),
        estimate: task.estimate ?? null,
      }}
      onSubmit={handleSubmit}
      isSubmitting={update.isPending}
    />
  );
}
  • Step 19.3: Smoke test in dev:
npm start

Click New task, fill title, set priority/project/due via pills, save. Verify the row appears in the list. Open Edit on an existing task, change a field, save. Verify the change.

  • Step 19.4: Lint, commit:
npm run lint
git add src/renderer/components/tasks/NewTaskDialog.tsx src/renderer/components/tasks/EditTaskDialog.tsx
git commit -m "refactor: NewTaskDialog/EditTaskDialog become wrappers around TaskFormDialog"

Lessons learned: (filled during execution)


Files:

  • Modify: adiuvAI/src/renderer/components/tasks/TaskFormDialog.tsx

Steps:

  • Step 20.1: Add imports:
import { Paperclip } from 'lucide-react';
import { useTaskAttachments } from './useTaskAttachments';
  • Step 20.2: In the component body (after the existing hooks), wire attachments only when in edit mode:
const attachments = useTaskAttachments(mode === 'edit' && taskId ? taskId : null);
  • Step 20.3: Modify the footer to show a 📎 icon-button on the left when mode === 'edit':
<div className="flex items-center justify-between gap-2 px-5 py-3 border-t border-border/40 bg-background/30">
  <div>
    {mode === 'edit' && (
      <Button
        type="button"
        variant="outline"
        size="icon"
        className="h-8 w-8"
        onClick={() => attachments.addFiles()}
        title={t('tasks.addFile')}
      >
        <Paperclip className="h-4 w-4" />
      </Button>
    )}
  </div>
  <div className="flex items-center gap-2">
    {/* existing Cancel + Submit buttons */}
  </div>
</div>
  • Step 20.4: Smoke test: open Edit on a task, click 📎, pick a file, confirm it appears in the detail sheet's attachments strip after closing the dialog.
npm start
  • Step 20.5: Lint, commit:
npm run lint
git add src/renderer/components/tasks/TaskFormDialog.tsx
git commit -m "feat: TaskFormDialog edit-mode 📎 attach pill"

Lessons learned: (filled during execution)


Phase E — Table, pager, list view

Task 21: TaskTable component (5 columns)

Files:

  • Create: adiuvAI/src/renderer/components/tasks/TaskTable.tsx

Steps:

  • Step 21.1: Create the table:
import { ChevronRight } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from '@tanstack/react-router';
import {
  Table, TableHeader, TableBody, TableRow, TableHead, TableCell,
} from '@/components/ui/table';
import { useFormatPrefs, formatDueDate, isOverdue } from '@/lib/date';
import { parseAssignees, type TaskItem } from './TaskRow';
import { PriorityBadge } from './PriorityBadge';
import { AssigneeStack } from './AssigneeStack';
import { TaskTableRow } from './TaskTableRow';
import { cn } from '@/lib/utils';

interface Props {
  tasks: TaskItem[];
  hideProjectColumn?: boolean;
  onRowClick: (task: TaskItem) => void;
  onEdit: (task: TaskItem) => void;
  onDelete: (id: string) => void;
  onStatusChange: (id: string, status: string) => void;
}

export function TaskTable({ tasks, hideProjectColumn, onRowClick, onEdit, onDelete, onStatusChange }: Props) {
  const { t } = useTranslation();
  const prefs = useFormatPrefs();
  const navigate = useNavigate();

  return (
    <div className="rounded-lg border border-border/50 bg-card/65 backdrop-blur-xl shadow-sm overflow-hidden">
      <Table>
        <TableHeader>
          <TableRow className="hover:bg-transparent">
            <TableHead>{t('tasks.colTask')}</TableHead>
            {!hideProjectColumn && <TableHead>{t('tasks.colProject')}</TableHead>}
            <TableHead>{t('tasks.colPriority')}</TableHead>
            <TableHead>{t('tasks.colDue')}</TableHead>
            <TableHead>{t('tasks.colAssignee')}</TableHead>
          </TableRow>
        </TableHeader>
        <TableBody>
          {tasks.map((task) => (
            <TaskTableRow
              key={task.id}
              task={task}
              hideProjectColumn={hideProjectColumn}
              onClick={() => onRowClick(task)}
              onEdit={() => onEdit(task)}
              onDelete={() => onDelete(task.id)}
              onStatusChange={(s) => onStatusChange(task.id, s)}
              onProjectClick={(projectId) => navigate({ to: '/projects', search: { projectId } })}
              prefs={prefs}
            />
          ))}
        </TableBody>
      </Table>
    </div>
  );
}

If isOverdue does not yet exist in lib/date.ts, add it there:

export function isOverdue(ts: number): boolean {
  return ts < Date.now();
}
  • Step 21.2: Lint (the file references TaskTableRow which doesn't exist yet — defer commit until next task).

Lessons learned: (filled during execution)


Task 22: TaskTableRow with context menu (edit / delete / change status submenu)

Files:

  • Create: adiuvAI/src/renderer/components/tasks/TaskTableRow.tsx

Steps:

  • Step 22.1: Create the row:
import { useTranslation } from 'react-i18next';
import { ChevronRight, Pencil, Trash2, Check } from 'lucide-react';
import {
  ContextMenu, ContextMenuTrigger, ContextMenuContent, ContextMenuItem,
  ContextMenuSub, ContextMenuSubTrigger, ContextMenuSubContent,
} from '@/components/ui/context-menu';
import { TableRow, TableCell } from '@/components/ui/table';
import { cn } from '@/lib/utils';
import { formatDueDate, isOverdue, type FormatPrefs } from '@/lib/date';
import { parseAssignees, type TaskItem } from './TaskRow';
import { PriorityBadge } from './PriorityBadge';
import { AssigneeStack } from './AssigneeStack';

const STATUSES = ['todo', 'in_progress', 'done'] as const;

export function TaskTableRow({
  task,
  hideProjectColumn,
  onClick,
  onEdit,
  onDelete,
  onStatusChange,
  onProjectClick,
  prefs,
}: {
  task: TaskItem;
  hideProjectColumn?: boolean;
  onClick: () => void;
  onEdit: () => void;
  onDelete: () => void;
  onStatusChange: (status: string) => void;
  onProjectClick: (projectId: string) => void;
  prefs: FormatPrefs;
}) {
  const { t } = useTranslation();
  const assignees = parseAssignees(task.assignee);
  const overdue = task.dueDate ? isOverdue(task.dueDate) && task.status !== 'done' : false;

  return (
    <ContextMenu>
      <ContextMenuTrigger asChild>
        <TableRow className="cursor-pointer" onClick={onClick}>
          <TableCell className="font-medium max-w-[280px] truncate">{task.title}</TableCell>
          {!hideProjectColumn && (
            <TableCell
              className="text-xs"
              onClick={(e) => {
                e.stopPropagation();
                if (task.projectId) onProjectClick(task.projectId);
              }}
            >
              {task.clientName && <span className="text-muted-foreground">{task.clientName}</span>}
              {task.clientName && task.projectName && <ChevronRight className="inline h-3 w-3 mx-1 text-muted-foreground" />}
              {task.projectName && <span className="hover:underline">{task.projectName}</span>}
              {!task.projectName && <span className="text-muted-foreground"></span>}
            </TableCell>
          )}
          <TableCell><PriorityBadge priority={task.priority} /></TableCell>
          <TableCell className={cn(overdue && 'text-red-600 dark:text-red-400')}>
            {task.dueDate ? formatDueDate(task.dueDate, prefs) : <span className="text-muted-foreground"></span>}
          </TableCell>
          <TableCell><AssigneeStack assignees={assignees} /></TableCell>
        </TableRow>
      </ContextMenuTrigger>
      <ContextMenuContent>
        <ContextMenuItem onSelect={onEdit}>
          <Pencil className="h-4 w-4 mr-2" />{t('common.edit')}
        </ContextMenuItem>
        <ContextMenuSub>
          <ContextMenuSubTrigger>{t('tasks.changeStatus')}</ContextMenuSubTrigger>
          <ContextMenuSubContent>
            {STATUSES.map((s) => (
              <ContextMenuItem key={s} onSelect={() => onStatusChange(s)}>
                {task.status === s && <Check className="h-3 w-3 mr-2" />}
                {!(task.status === s) && <span className="w-5" />}
                {t(s === 'todo' ? 'tasks.toDo' : s === 'in_progress' ? 'tasks.inProgress' : 'tasks.done')}
              </ContextMenuItem>
            ))}
          </ContextMenuSubContent>
        </ContextMenuSub>
        <ContextMenuItem onSelect={onDelete} className="text-destructive focus:text-destructive">
          <Trash2 className="h-4 w-4 mr-2" />{t('common.delete')}
        </ContextMenuItem>
      </ContextMenuContent>
    </ContextMenu>
  );
}
  • Step 22.2: Export FormatPrefs from lib/date.ts if it isn't already:
export type FormatPrefs = ReturnType<typeof useFormatPrefs>;
  • Step 22.3: Lint, commit Tasks 21 + 22 together:
npm run lint
git add src/renderer/components/tasks/TaskTable.tsx src/renderer/components/tasks/TaskTableRow.tsx src/renderer/lib/date.ts
git commit -m "feat: TaskTable + TaskTableRow with context menu and status submenu"

Lessons learned: (filled during execution)


Task 23: TaskPager with numbered buttons and ResizeObserver

Files:

  • Create: adiuvAI/src/renderer/components/tasks/TaskPager.tsx

Steps:

  • Step 23.1: Create the pager:
import { useEffect, useRef, useState } from 'react';
import { useTranslation, Trans } from 'react-i18next';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { cn } from '@/lib/utils';

interface Props {
  total: number;
  pageIndex: number;
  pageSize: number;
  onPageChange: (page: number) => void;
  onPageSizeChange: (size: number) => void;
}

const PAGE_SIZES = [10, 25, 50, 100];

function buildWindow(current: number, last: number, max: number): Array<number | 'ellipsis'> {
  if (last <= max) return Array.from({ length: last + 1 }, (_, i) => i);
  const window: Array<number | 'ellipsis'> = [];
  const halfMax = Math.floor((max - 2) / 2); // reserve slots for first + last
  let start = Math.max(1, current - halfMax);
  let end = Math.min(last - 1, current + halfMax);
  if (current - halfMax < 1) end = Math.min(last - 1, max - 2);
  if (current + halfMax > last - 1) start = Math.max(1, last - (max - 2));
  window.push(0);
  if (start > 1) window.push('ellipsis');
  for (let i = start; i <= end; i++) window.push(i);
  if (end < last - 1) window.push('ellipsis');
  window.push(last);
  return window;
}

export function TaskPager({ total, pageIndex, pageSize, onPageChange, onPageSizeChange }: Props) {
  const { t } = useTranslation();
  const containerRef = useRef<HTMLDivElement>(null);
  const [maxButtons, setMaxButtons] = useState(7);

  useEffect(() => {
    const el = containerRef.current;
    if (!el) return;
    const ro = new ResizeObserver(([entry]) => {
      const w = entry.contentRect.width;
      setMaxButtons(w < 480 ? 3 : w < 640 ? 5 : 7);
    });
    ro.observe(el);
    return () => ro.disconnect();
  }, []);

  const lastPage = Math.max(0, Math.ceil(total / pageSize) - 1);
  const start = total === 0 ? 0 : pageIndex * pageSize + 1;
  const end = Math.min(total, (pageIndex + 1) * pageSize);
  const window = buildWindow(pageIndex, lastPage, maxButtons);

  return (
    <div
      ref={containerRef}
      className="rounded-lg border border-border/50 bg-card/65 backdrop-blur-xl shadow-sm flex items-center justify-between px-4 py-2 gap-3 flex-wrap"
    >
      <span className="text-xs text-muted-foreground">
        <Trans i18nKey="tasks.showingNofM" values={{ start, end, total }} components={{ b: <span className="font-medium text-foreground" /> }} />
      </span>
      <div className="flex items-center gap-2">
        <span className="text-xs text-muted-foreground">{t('tasks.rowsPerPage')}</span>
        <Select value={String(pageSize)} onValueChange={(v) => onPageSizeChange(Number(v))}>
          <SelectTrigger className="h-7 w-[68px]"><SelectValue /></SelectTrigger>
          <SelectContent>
            {PAGE_SIZES.map((s) => <SelectItem key={s} value={String(s)}>{s}</SelectItem>)}
          </SelectContent>
        </Select>
        <Button variant="ghost" size="icon" className="h-7 w-7" disabled={pageIndex === 0} onClick={() => onPageChange(pageIndex - 1)}>
          <ChevronLeft className="h-4 w-4" />
        </Button>
        {window.map((p, i) =>
          p === 'ellipsis' ? (
            <span key={`e${i}`} className="px-1 text-muted-foreground"></span>
          ) : (
            <Button
              key={p}
              variant={p === pageIndex ? 'default' : 'ghost'}
              size="sm"
              className={cn('h-7 min-w-7 px-2 text-xs')}
              onClick={() => onPageChange(p)}
            >
              {p + 1}
            </Button>
          ),
        )}
        <Button variant="ghost" size="icon" className="h-7 w-7" disabled={pageIndex >= lastPage} onClick={() => onPageChange(pageIndex + 1)}>
          <ChevronRight className="h-4 w-4" />
        </Button>
      </div>
    </div>
  );
}
  • Step 23.2: Lint, commit:
npm run lint
git add src/renderer/components/tasks/TaskPager.tsx
git commit -m "feat: TaskPager with numbered buttons and ResizeObserver-aware width"

Lessons learned: (filled during execution)


Task 24: TaskListView orchestrator

Files:

  • Create: adiuvAI/src/renderer/components/tasks/TaskListView.tsx

Steps:

  • Step 24.1: Create the orchestrator. It owns: search, status tabs, order-by, view toggle, page size + page index, and the New task button. It receives the task array (and an optional projectId to scope the New task default).
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Plus, Search, List, LayoutGrid } from 'lucide-react';
import { trpc } from '@/lib/trpc';
import { useNotify } from '@/hooks/useNotify';
import { Button } from '@/components/ui/button';
import { InputGroup, InputGroupAddon, InputGroupInput } from '@/components/ui/input-group';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import { Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyDescription } from '@/components/ui/empty';
import { ClipboardCheck } from 'lucide-react';
import { TaskTable } from './TaskTable';
import { TaskCard } from './TaskCard';
import { TaskPager } from './TaskPager';
import { TaskDetailSheet } from './TaskDetailSheet';
import { NewTaskDialog } from './NewTaskDialog';
import { EditTaskDialog } from './EditTaskDialog';
import { type TaskItem } from './TaskRow';

type StatusFilter = 'active' | 'todo' | 'in_progress' | 'all' | 'done';
type OrderBy = 'dueDate' | 'priority' | 'createdAt';

const PAGE_SIZE_KEY = 'tasksPageSize';
const VIEW_MODE_KEY = 'tasksViewMode';

function readPageSize(): number {
  const v = Number(localStorage.getItem(PAGE_SIZE_KEY));
  return [10, 25, 50, 100].includes(v) ? v : 25;
}

function readViewMode(): 'list' | 'grid' {
  return (localStorage.getItem(VIEW_MODE_KEY) as 'list' | 'grid') ?? 'list';
}

export function TaskListView({
  projectId,
  hideProjectColumn,
}: {
  projectId?: string;
  hideProjectColumn?: boolean;
}) {
  const { t } = useTranslation();
  const utils = trpc.useUtils();
  const { notify, notifyError } = useNotify();

  const [search, setSearch] = useState('');
  const [debouncedSearch, setDebouncedSearch] = useState('');
  const [statusFilter, setStatusFilter] = useState<StatusFilter>('active');
  const [orderBy, setOrderBy] = useState<OrderBy>('dueDate');
  const [viewMode, setViewMode] = useState<'list' | 'grid'>(readViewMode);
  const [pageSize, setPageSize] = useState<number>(readPageSize);
  const [pageIndex, setPageIndex] = useState(0);

  const [newOpen, setNewOpen] = useState(false);
  const [editTask, setEditTask] = useState<TaskItem | null>(null);
  const [viewTask, setViewTask] = useState<TaskItem | null>(null);

  useEffect(() => { localStorage.setItem(VIEW_MODE_KEY, viewMode); }, [viewMode]);
  useEffect(() => { localStorage.setItem(PAGE_SIZE_KEY, String(pageSize)); }, [pageSize]);

  // Reset page on any filter change
  useEffect(() => { setPageIndex(0); }, [debouncedSearch, statusFilter, orderBy]);

  // Search debounce
  useEffect(() => {
    const id = setTimeout(() => setDebouncedSearch(search), 300);
    return () => clearTimeout(id);
  }, [search]);

  const backendStatus = statusFilter === 'todo' || statusFilter === 'in_progress' || statusFilter === 'done'
    ? statusFilter : undefined;

  const queryInput = useMemo(() => ({
    ...(backendStatus ? { status: backendStatus } : {}),
    ...(debouncedSearch.trim() ? { search: debouncedSearch.trim() } : {}),
    ...(projectId ? { projectId } : {}),
    orderBy,
  }), [backendStatus, debouncedSearch, orderBy, projectId]);

  const { data: filteredTasks } = trpc.tasks.list.useQuery(queryInput);

  const updateTask = trpc.tasks.update.useMutation({
    onSuccess: () => void utils.tasks.list.invalidate(),
    onError: (err) => notifyError('toast.task.updateError', err),
  });
  const deleteTask = trpc.tasks.delete.useMutation({
    onSuccess: () => {
      notify('warning', 'toast.task.deleted');
      void utils.tasks.list.invalidate();
    },
    onError: (err) => notifyError('toast.task.deleteError', err),
  });

  const tasksAll = (filteredTasks ?? [])
    .filter((t) => statusFilter !== 'active' || t.status === 'todo' || t.status === 'in_progress');

  const total = tasksAll.length;
  const lastPage = Math.max(0, Math.ceil(total / pageSize) - 1);
  const safePageIndex = Math.min(pageIndex, lastPage);
  if (safePageIndex !== pageIndex) setPageIndex(safePageIndex);
  const pageTasks = tasksAll.slice(safePageIndex * pageSize, (safePageIndex + 1) * pageSize);

  return (
    <div className="flex flex-col gap-4">
      {/* Toolbar */}
      <div className="flex flex-wrap items-center justify-between gap-3">
        <Tabs value={statusFilter} onValueChange={(v) => setStatusFilter(v as StatusFilter)}>
          <TabsList>
            <TabsTrigger value="active">{t('tasks.active')}</TabsTrigger>
            <TabsTrigger value="todo">{t('tasks.toDo')}</TabsTrigger>
            <TabsTrigger value="in_progress">{t('tasks.inProgress')}</TabsTrigger>
            <TabsTrigger value="done">{t('tasks.done')}</TabsTrigger>
            <TabsTrigger value="all">{t('tasks.all')}</TabsTrigger>
          </TabsList>
        </Tabs>
        <div className="flex flex-wrap items-center gap-3">
          <InputGroup className="w-56">
            <InputGroupAddon><Search /></InputGroupAddon>
            <InputGroupInput placeholder={t('tasks.searchPlaceholder')} value={search} onChange={(e) => setSearch(e.target.value)} />
          </InputGroup>
          <Select value={orderBy} onValueChange={(v) => setOrderBy(v as OrderBy)}>
            <SelectTrigger className="w-[180px]"><SelectValue /></SelectTrigger>
            <SelectContent>
              <SelectItem value="dueDate">{t('tasks.orderByDue')}</SelectItem>
              <SelectItem value="priority">{t('tasks.orderByPriority')}</SelectItem>
              <SelectItem value="createdAt">{t('tasks.orderByCreated')}</SelectItem>
            </SelectContent>
          </Select>
          <ToggleGroup type="single" value={viewMode} onValueChange={(v) => v && setViewMode(v as 'list' | 'grid')} variant="outline" size="sm">
            <ToggleGroupItem value="list"><List /></ToggleGroupItem>
            <ToggleGroupItem value="grid"><LayoutGrid /></ToggleGroupItem>
          </ToggleGroup>
          <Button size="sm" onClick={() => setNewOpen(true)}>
            <Plus className="h-4 w-4 mr-1" />{t('tasks.newTask')}
          </Button>
        </div>
      </div>

      {/* Body */}
      {total === 0 ? (
        <Empty>
          <EmptyHeader>
            <EmptyMedia variant="icon"><ClipboardCheck /></EmptyMedia>
            <EmptyTitle>{t('tasks.noTasksFound')}</EmptyTitle>
            <EmptyDescription>{t('tasks.noTasksDescription')}</EmptyDescription>
          </EmptyHeader>
        </Empty>
      ) : viewMode === 'grid' ? (
        <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3">
          {pageTasks.map((task) => (
            <TaskCard
              key={task.id}
              task={task}
              onToggle={(id, status) => {
                const next = status === 'todo' ? 'in_progress' : status === 'in_progress' ? 'done' : 'todo';
                updateTask.mutate({ id, status: next });
              }}
              onEdit={setEditTask}
              onDelete={(id) => deleteTask.mutate({ id })}
              onClick={setViewTask}
            />
          ))}
        </div>
      ) : (
        <TaskTable
          tasks={pageTasks}
          hideProjectColumn={hideProjectColumn}
          onRowClick={setViewTask}
          onEdit={setEditTask}
          onDelete={(id) => deleteTask.mutate({ id })}
          onStatusChange={(id, status) => updateTask.mutate({ id, status })}
        />
      )}

      {/* Pager (always visible when there are tasks) */}
      {total > 0 && (
        <TaskPager
          total={total}
          pageIndex={safePageIndex}
          pageSize={pageSize}
          onPageChange={setPageIndex}
          onPageSizeChange={(s) => { setPageSize(s); setPageIndex(0); }}
        />
      )}

      <NewTaskDialog open={newOpen} onOpenChange={setNewOpen} defaultProjectId={projectId} />
      <EditTaskDialog task={editTask} open={!!editTask} onOpenChange={(o) => { if (!o) setEditTask(null); }} />
      <TaskDetailSheet
        task={viewTask}
        open={!!viewTask}
        onOpenChange={(o) => { if (!o) setViewTask(null); }}
        onEdit={(task) => { setViewTask(null); setEditTask(task); }}
        onDelete={(id) => { deleteTask.mutate({ id }); setViewTask(null); }}
      />
    </div>
  );
}
  • Step 24.2: Lint, commit:
npm run lint
git add src/renderer/components/tasks/TaskListView.tsx
git commit -m "feat: TaskListView orchestrator (toolbar + table/grid + pager)"

Lessons learned: (filled during execution)


Task 25: Wire TaskListView into routes/tasks.tsx; delete TaskRow

Files:

  • Modify: adiuvAI/src/renderer/routes/tasks.tsx
  • Delete: adiuvAI/src/renderer/components/tasks/TaskRow.tsx

Steps:

  • Step 25.1: Replace the bulk of tasks.tsx body. Keep only: header stat cards + a <TaskListView /> for the body. The dialogs and detail sheet move into TaskListView. Final file:
import { createFileRoute } from '@tanstack/react-router';
import { useEffect, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { ClipboardCheck, ListTodo, Clock, CheckCircle2 } from 'lucide-react';
import { useFloatingChat } from '@/context/FloatingChatContext';
import { trpc } from '@/lib/trpc';
import { Item, ItemMedia, ItemContent, ItemTitle, ItemDescription } from '@/components/ui/item';
import { TaskListView } from '@/components/tasks/TaskListView';

export const Route = createFileRoute('/tasks')({ component: TasksPage });

function TasksPage() {
  const { t } = useTranslation();
  const overviewRef = useRef<HTMLDivElement>(null);
  const listRef = useRef<HTMLDivElement>(null);
  const { registerSection, unregisterSection } = useFloatingChat();

  useEffect(() => {
    registerSection({ id: 'tasks-overview', label: 'Tasks Overview', ref: overviewRef });
    registerSection({ id: 'tasks-list', label: 'Task List', ref: listRef });
    return () => {
      unregisterSection('tasks-overview');
      unregisterSection('tasks-list');
    };
  }, [registerSection, unregisterSection]);

  const { data: allTasks } = trpc.tasks.list.useQuery({});
  const stats = useMemo(() => {
    const all = allTasks ?? [];
    return {
      total: all.length,
      todo: all.filter((t) => t.status === 'todo').length,
      inProgress: all.filter((t) => t.status === 'in_progress').length,
      completed: all.filter((t) => t.status === 'done').length,
    };
  }, [allTasks]);

  return (
    <div className="flex flex-col gap-6 p-6 pt-0 w-full">
      <div ref={overviewRef} data-ai-section="tasks-overview" className="grid grid-cols-4 gap-4">
        <Item variant="muted">
          <ItemMedia variant="icon"><ClipboardCheck /></ItemMedia>
          <ItemContent><ItemTitle>{stats.total}</ItemTitle><ItemDescription>{t('tasks.totalTasks')}</ItemDescription></ItemContent>
        </Item>
        <Item variant="muted">
          <ItemMedia variant="icon"><ListTodo /></ItemMedia>
          <ItemContent><ItemTitle>{stats.todo}</ItemTitle><ItemDescription>{t('tasks.toDo')}</ItemDescription></ItemContent>
        </Item>
        <Item variant="muted" className="bg-sky-50 dark:bg-sky-950/30">
          <ItemMedia variant="icon"><Clock /></ItemMedia>
          <ItemContent><ItemTitle>{stats.inProgress}</ItemTitle><ItemDescription>{t('tasks.inProgress')}</ItemDescription></ItemContent>
        </Item>
        <Item variant="muted" className="bg-green-50 dark:bg-green-950/30">
          <ItemMedia variant="icon"><CheckCircle2 /></ItemMedia>
          <ItemContent><ItemTitle>{stats.completed}</ItemTitle><ItemDescription>{t('tasks.completed')}</ItemDescription></ItemContent>
        </Item>
      </div>

      <div ref={listRef} data-ai-section="tasks-list">
        <TaskListView />
      </div>
    </div>
  );
}
  • Step 25.2: Move the TaskItem type out of TaskRow.tsx to avoid losing it. Create src/renderer/components/tasks/task-types.ts:
export type TaskItem = {
  id: string;
  projectId: string | null;
  title: string;
  description: string | null;
  status: string | null;
  priority: string | null;
  assignee: string | null;
  dueDate: number | null;
  createdAt: number | null;
  estimate: number | null;
  isAiSuggested: number;
  projectName: string | null;
  clientName: string | null;
  subClientName: string | null;
};
  • Step 25.3: Update every importer that did import { type TaskItem } from './TaskRow' to instead import from ./task-types. Use grep:
grep -rn "from '.*tasks/TaskRow'" src/
grep -rn "from '@/components/tasks/TaskRow'" src/

Adjust each file. The function parseAssignees already lives in task-utils.ts — leave its imports alone.

  • Step 25.4: Delete TaskRow.tsx:
git rm src/renderer/components/tasks/TaskRow.tsx

Verify nothing imports it: grep -rn "TaskRow" src/. Expect: only task-types.ts mention if any.

  • Step 25.5: Smoke test:
npm start

Verify the Tasks page: stat cards render, table view shows tasks, grid view works, search/filter/order-by/view-toggle/pagination all work, click row opens sheet, edit works, delete works, context menu status submenu changes status.

  • Step 25.6: Lint, commit:
npm run lint
git add src/renderer/routes/tasks.tsx src/renderer/components/tasks/task-types.ts src/renderer/components/tasks/TaskRow.tsx
# plus any importer updates
git commit -m "refactor: tasks route uses TaskListView; delete TaskRow"

Lessons learned: (filled during execution)


Phase F — Project page integration

Task 26: Replace KanbanBoard with TaskListView in ProjectDetail

Files:

  • Modify: adiuvAI/src/renderer/components/projects/ProjectDetail.tsx
  • Delete: adiuvAI/src/renderer/components/projects/KanbanBoard.tsx

Steps:

  • Step 26.1: Open ProjectDetail.tsx. Locate the <KanbanBoard ... /> block (~line 571) and the surrounding tab content. Replace with:
<TaskListView projectId={projectId} hideProjectColumn />

Remove the newTaskOpen / setNewTaskOpen state if it was only used by the kanban (the Add task button is now inside TaskListView). If the project page header has a separate + button for adding a task, replace that with a dispatch into a fresh NewTaskDialog or simply delete it (TaskListView provides its own).

  • Step 26.2: Replace the KanbanBoard import:
import { TaskListView } from '@/components/tasks/TaskListView';
// remove: import { KanbanBoard } from './KanbanBoard';
  • Step 26.3: Delete the kanban file:
git rm src/renderer/components/projects/KanbanBoard.tsx

Verify no other importer: grep -rn "KanbanBoard" src/. Expect: no matches.

  • Step 26.4: Smoke test:
npm start

Open a project from /projects?projectId=.... Verify the tasks tab now shows the table view (Project column hidden), pagination works, the New task button defaults the project to the current one, and clicking a row opens the same sheet. Verify other tabs (overview, notes, timeline) are unaffected.

  • Step 26.5: Lint, commit:
npm run lint
git add src/renderer/components/projects/ProjectDetail.tsx src/renderer/components/projects/KanbanBoard.tsx
git commit -m "refactor: project page tasks tab uses TaskListView with hideProjectColumn"

Lessons learned: (filled during execution)


Phase G — i18n

Task 27: Add new keys to all 5 locales

Files:

  • Modify: adiuvAI/src/renderer/locales/en/translation.json
  • Modify: adiuvAI/src/renderer/locales/it/translation.json
  • Modify: adiuvAI/src/renderer/locales/es/translation.json
  • Modify: adiuvAI/src/renderer/locales/fr/translation.json
  • Modify: adiuvAI/src/renderer/locales/de/translation.json

Steps:

  • Step 27.1: Add the following keys to the tasks namespace of en/translation.json:
{
  "tasks": {
    "colTask": "Task",
    "colProject": "Project",
    "colPriority": "Priority",
    "colDue": "Due",
    "colAssignee": "Assignee",
    "rowsPerPage": "Rows per page",
    "showingNofM": "Showing <b>{{start}}{{end}}</b> of <b>{{total}}</b> tasks",
    "noAssignees": "Unassigned",
    "estimate": "Estimate",
    "attachments": "Attachments",
    "addFile": "Add",
    "removeFile": "Remove",
    "fileTooLarge": "{{filename}} exceeds the 50 MB limit and was skipped.",
    "changeStatus": "Change status",
    "properties": "Properties",
    "confirmDeleteAttachment": "Delete this attachment?",
    "writeComment": "Write a comment…",
    "comments": "Comments",
    "description": "Description",
    "noDescription": "No description provided.",
    "due": "Due",
    "created": "Created",
    "assignees": "Assignees",
    "assignee": "Assignee",
    "project": "Project",
    "priority": "Priority",
    "status": "Status",
    "noProject": "No project",
    "newProject": "New project",
    "whatNeedsToBeDone": "What needs to be done?",
    "createTask": "Create task",
    "editTask": "Edit task",
    "orderByDue": "Order by Due Date",
    "orderByPriority": "Order by Priority",
    "orderByCreated": "Order by Created Date"
  }
}

Merge — do not replace — into the existing tasks block. Keep alphabetical or grouped order consistent with what's already there.

  • Step 27.2: Translate the same keys for it/translation.json (Italian):
{
  "colTask": "Attività",
  "colProject": "Progetto",
  "colPriority": "Priorità",
  "colDue": "Scadenza",
  "colAssignee": "Assegnatario",
  "rowsPerPage": "Righe per pagina",
  "showingNofM": "Mostrate <b>{{start}}{{end}}</b> di <b>{{total}}</b> attività",
  "noAssignees": "Nessun assegnatario",
  "estimate": "Stima",
  "attachments": "Allegati",
  "addFile": "Aggiungi",
  "removeFile": "Rimuovi",
  "fileTooLarge": "{{filename}} supera il limite di 50 MB ed è stato saltato.",
  "changeStatus": "Cambia stato",
  "properties": "Proprietà",
  "confirmDeleteAttachment": "Eliminare questo allegato?",
  "writeComment": "Scrivi un commento…",
  "comments": "Commenti",
  "description": "Descrizione",
  "noDescription": "Nessuna descrizione.",
  "due": "Scadenza",
  "created": "Creato",
  "assignees": "Assegnatari",
  "assignee": "Assegnatario",
  "project": "Progetto",
  "priority": "Priorità",
  "status": "Stato",
  "noProject": "Nessun progetto",
  "newProject": "Nuovo progetto",
  "whatNeedsToBeDone": "Cosa devi fare?",
  "createTask": "Crea attività",
  "editTask": "Modifica attività",
  "orderByDue": "Ordina per scadenza",
  "orderByPriority": "Ordina per priorità",
  "orderByCreated": "Ordina per data creazione"
}
  • Step 27.3: Spanish (es/translation.json):
{
  "colTask": "Tarea",
  "colProject": "Proyecto",
  "colPriority": "Prioridad",
  "colDue": "Vence",
  "colAssignee": "Asignado",
  "rowsPerPage": "Filas por página",
  "showingNofM": "Mostrando <b>{{start}}{{end}}</b> de <b>{{total}}</b> tareas",
  "noAssignees": "Sin asignar",
  "estimate": "Estimación",
  "attachments": "Adjuntos",
  "addFile": "Añadir",
  "removeFile": "Quitar",
  "fileTooLarge": "{{filename}} supera el límite de 50 MB y fue omitido.",
  "changeStatus": "Cambiar estado",
  "properties": "Propiedades",
  "confirmDeleteAttachment": "¿Eliminar este adjunto?",
  "writeComment": "Escribe un comentario…",
  "comments": "Comentarios",
  "description": "Descripción",
  "noDescription": "Sin descripción.",
  "due": "Vence",
  "created": "Creado",
  "assignees": "Asignados",
  "assignee": "Asignado",
  "project": "Proyecto",
  "priority": "Prioridad",
  "status": "Estado",
  "noProject": "Sin proyecto",
  "newProject": "Nuevo proyecto",
  "whatNeedsToBeDone": "¿Qué hay que hacer?",
  "createTask": "Crear tarea",
  "editTask": "Editar tarea",
  "orderByDue": "Ordenar por fecha de vencimiento",
  "orderByPriority": "Ordenar por prioridad",
  "orderByCreated": "Ordenar por fecha de creación"
}
  • Step 27.4: French (fr/translation.json):
{
  "colTask": "Tâche",
  "colProject": "Projet",
  "colPriority": "Priorité",
  "colDue": "Échéance",
  "colAssignee": "Assigné à",
  "rowsPerPage": "Lignes par page",
  "showingNofM": "Affichage <b>{{start}}{{end}}</b> sur <b>{{total}}</b> tâches",
  "noAssignees": "Non assignée",
  "estimate": "Estimation",
  "attachments": "Pièces jointes",
  "addFile": "Ajouter",
  "removeFile": "Retirer",
  "fileTooLarge": "{{filename}} dépasse la limite de 50 Mo et a été ignoré.",
  "changeStatus": "Changer le statut",
  "properties": "Propriétés",
  "confirmDeleteAttachment": "Supprimer cette pièce jointe ?",
  "writeComment": "Écrire un commentaire…",
  "comments": "Commentaires",
  "description": "Description",
  "noDescription": "Aucune description.",
  "due": "Échéance",
  "created": "Créé",
  "assignees": "Assignés",
  "assignee": "Assigné à",
  "project": "Projet",
  "priority": "Priorité",
  "status": "Statut",
  "noProject": "Aucun projet",
  "newProject": "Nouveau projet",
  "whatNeedsToBeDone": "Que faut-il faire ?",
  "createTask": "Créer la tâche",
  "editTask": "Modifier la tâche",
  "orderByDue": "Trier par échéance",
  "orderByPriority": "Trier par priorité",
  "orderByCreated": "Trier par date de création"
}
  • Step 27.5: German (de/translation.json):
{
  "colTask": "Aufgabe",
  "colProject": "Projekt",
  "colPriority": "Priorität",
  "colDue": "Fällig",
  "colAssignee": "Zugewiesen",
  "rowsPerPage": "Zeilen pro Seite",
  "showingNofM": "Zeige <b>{{start}}{{end}}</b> von <b>{{total}}</b> Aufgaben",
  "noAssignees": "Nicht zugewiesen",
  "estimate": "Schätzung",
  "attachments": "Anhänge",
  "addFile": "Hinzufügen",
  "removeFile": "Entfernen",
  "fileTooLarge": "{{filename}} überschreitet das 50-MB-Limit und wurde übersprungen.",
  "changeStatus": "Status ändern",
  "properties": "Eigenschaften",
  "confirmDeleteAttachment": "Diesen Anhang löschen?",
  "writeComment": "Kommentar schreiben…",
  "comments": "Kommentare",
  "description": "Beschreibung",
  "noDescription": "Keine Beschreibung.",
  "due": "Fällig",
  "created": "Erstellt",
  "assignees": "Zugewiesene",
  "assignee": "Zugewiesen",
  "project": "Projekt",
  "priority": "Priorität",
  "status": "Status",
  "noProject": "Kein Projekt",
  "newProject": "Neues Projekt",
  "whatNeedsToBeDone": "Was ist zu tun?",
  "createTask": "Aufgabe erstellen",
  "editTask": "Aufgabe bearbeiten",
  "orderByDue": "Nach Fälligkeit sortieren",
  "orderByPriority": "Nach Priorität sortieren",
  "orderByCreated": "Nach Erstellungsdatum sortieren"
}
  • Step 27.6: Re-launch dev and switch language in Settings → General. Verify the Tasks page strings render in each language.
npm start
  • Step 27.7: Lint, commit:
npm run lint
git add src/renderer/locales/en/translation.json \
         src/renderer/locales/it/translation.json \
         src/renderer/locales/es/translation.json \
         src/renderer/locales/fr/translation.json \
         src/renderer/locales/de/translation.json
git commit -m "feat(i18n): add task list/sheet/dialog keys for all 5 languages"

Lessons learned: (filled during execution)


Task 28: Update i18n toast keys for new attachment + comment paths

Files:

  • Modify: locale files (all 5)

Steps:

  • Step 28.1: Add to each locale (within an existing toast namespace if present, otherwise create the keys at top level):

en:

{
  "toast": {
    "attachment": {
      "createError": "Could not attach file.",
      "tooLarge": "{{filename}} is too large (limit 50 MB)."
    }
  }
}

Provide translations in it, es, fr, de (one short sentence each — match existing toast tone).

  • Step 28.2: Lint, commit:
npm run lint
git add src/renderer/locales/
git commit -m "feat(i18n): add attachment toast keys for all 5 languages"

Lessons learned: (filled during execution)


Final verification

Task 29: End-to-end smoke test

Steps:

  • Step 29.1: Fresh build:
cd adiuvAI
npm run lint
npm start
  • Step 29.2: Walk the full UI on a real account with several tasks:

    • Tasks page renders the table view with 5 columns; transparent card; pager visible.
    • Switch list ↔ grid: pagination is preserved and the pager remains.
    • Change rows-per-page: list resets to page 0, pager reflects new total pages.
    • Right-click a row → context menu shows Edit, Change status submenu, Delete. Use the submenu to change status; verify the row updates.
    • Click the Project cell — navigates to the project page (which shows the same table with the Project column hidden).
    • Click a row → detail Sheet opens. Verify breadcrumb, title, priority + status chips. Click each chip → popover lets you change.
    • Properties card shows assignee, due, estimate placeholder, created. Files row shows existing attachments.
    • Click + Add — picker opens, pick 12 files, they appear as chips.
    • Click an attachment chip → file opens in OS default app.
    • Click × on a chip → row + file removed.
    • Description renders.
    • Comments list scrolls inline with the rest of the body (no inner scrollbar).
    • Composer (home-page-style input) sends a comment; it appears in the list.
    • No 📎 in the comment composer.
    • Overflow menu Edit → form dialog opens with values prefilled. Footer shows 📎 (because edit mode).
    • Overflow menu Delete → task disappears; attachments folder for that taskId is removed from disk (verify in Electron's userData).
    • New task → quick-capture dialog. Title input, description textarea, 5 property pills. Cmd/Ctrl+Enter creates.
    • Switch UI language to Italian — all new keys render in Italian.
  • Step 29.3: If any check fails, stop and address before declaring done. No commit for this task — it is verification only.

Lessons learned: (filled during execution)


Out of scope (do not do here)

  • AI estimate generation.
  • Comment attachments.
  • Per-column header sort.
  • Backend pagination of tasks.list.
  • Restoring the Kanban board as a third view (was intentionally dropped).

Lessons learned (project-level)

Aggregate lessons from per-task slots after completion.