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

2938 lines
93 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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`](./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`):
```ts
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`:
```ts
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:
```bash
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:
```bash
npx drizzle-kit push
```
- [ ] **Step 1.5:** Run lint:
```bash
npm run lint
```
Expected: passes.
- [ ] **Step 1.6:** Commit:
```bash
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`:
```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:
```bash
npm run lint
```
Expected: passes.
- [ ] **Step 2.3:** Commit:
```bash
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:
```ts
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:
```ts
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:
```bash
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:
```bash
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:
```ts
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:
```ts
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:
```bash
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.
```bash
npm start
```
- [ ] **Step 4.6:** Commit:
```bash
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:
```tsx
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:
```bash
npm run lint
```
- [ ] **Step 5.3:** Commit:
```bash
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:
```tsx
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:
```bash
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:
```tsx
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):
```ts
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:
```bash
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:
```ts
type ChatInputBoxVariant = 'panel' | 'floating' | 'comment';
```
- [ ] **Step 8.2:** Add a `comment` entry to `VARIANT_STYLES`:
```ts
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:
```bash
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:
```tsx
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:
```bash
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:
```tsx
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:
```tsx
<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:
```bash
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:
```tsx
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:
```tsx
<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):
```tsx
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:
```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;
};
```
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:
```bash
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:
```tsx
import { Plus } from 'lucide-react';
import { TaskAttachmentChip } from './TaskAttachmentChip';
import { useTaskAttachments } from './useTaskAttachments';
```
- [ ] **Step 12.2:** Inside the component body, wire the hook:
```tsx
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):
```tsx
<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:
```bash
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:
```bash
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:
```tsx
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:
```tsx
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:
```tsx
<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:
```tsx
<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:
```bash
npm run lint
```
- [ ] **Step 13.6:** Commit:
```bash
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`:
```tsx
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:
```bash
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:
```bash
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:
```bash
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:
```tsx
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
```
Also import the `tasks.update` mutation:
```tsx
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:
```tsx
<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`:
```tsx
<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:
```bash
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:
```tsx
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:
```bash
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:
```tsx
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:
```tsx
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:
```tsx
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:
```tsx
{/* 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:
```bash
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:
```tsx
{!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:
```bash
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:
```tsx
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:
```tsx
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:
```bash
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:
```bash
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)_
---
### Task 20: 📎 attach footer pill (Edit mode only)
**Files:**
- Modify: `adiuvAI/src/renderer/components/tasks/TaskFormDialog.tsx`
**Steps:**
- [ ] **Step 20.1:** Add imports:
```tsx
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:
```tsx
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'`:
```tsx
<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.
```bash
npm start
```
- [ ] **Step 20.5:** Lint, commit:
```bash
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:
```tsx
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:
```ts
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:
```tsx
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:
```ts
export type FormatPrefs = ReturnType<typeof useFormatPrefs>;
```
- [ ] **Step 22.3:** Lint, commit Tasks 21 + 22 together:
```bash
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:
```tsx
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:
```bash
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).
```tsx
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:
```bash
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:
```tsx
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`:
```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:
```bash
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`:
```bash
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:
```bash
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:
```bash
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:
```tsx
<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:
```tsx
import { TaskListView } from '@/components/tasks/TaskListView';
// remove: import { KanbanBoard } from './KanbanBoard';
```
- [ ] **Step 26.3:** Delete the kanban file:
```bash
git rm src/renderer/components/projects/KanbanBoard.tsx
```
Verify no other importer: `grep -rn "KanbanBoard" src/`. Expect: no matches.
- [ ] **Step 26.4:** Smoke test:
```bash
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:
```bash
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`:
```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):
```json
{
"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`):
```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`):
```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`):
```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.
```bash
npm start
```
- [ ] **Step 27.7:** Lint, commit:
```bash
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`:
```json
{
"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:
```bash
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:
```bash
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._