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).
93 KiB
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 starton macOS/Linux,cd adiuvAI; npm starton Windows. git addlists explicit files (nogit add -A).- Path alias
@/*resolves tosrc/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
estimateto thetaskstable definition (afterdueDate):
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
taskAttachmentstable definition aftertaskComments:
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
appRouterdeclaration:
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 theappRouterobject (alphabetically or aftertaskComments). -
Step 3.4: Re-export the helper for cascade use in tasks router. Confirm
deleteTaskDiris 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.updateprocedure. Addestimateto the input schema and to theset(...)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.createprocedure. Addestimateto its input schema (same shape asupdate). -
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_STYLESobject. Extend the type alias at the top:
type ChatInputBoxVariant = 'panel' | 'floating' | 'comment';
- Step 8.2: Add a
commententry toVARIANT_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
PropRowhelper 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 alongsidet. -
Step 11.5:
TaskItem(TaskRow.tsx) currently does not includecreatedAt. OpenTaskRow.tsxand add it to theTaskItemtype:
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 acol-span-2container):
<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 theTaskDetailDialogimport and usage withTaskDetailSheet:
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
TaskDetailDialogin 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>withtodo / 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 aPopoverfrom 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 intoTaskFormDialog. The state lives at component top level; the form view is shown inside the Project pill popover whencreatingProjectis 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
InlineProjectFormeither as a private component in this file or insrc/renderer/components/tasks/InlineProjectForm.tsx. Reuse the structure fromNewTaskDialog.tsxlines 379-526 (project name + client select w/ inline create + sub-client select w/ inline create). Wire totrpc.clients.createandtrpc.projects.createmutations. -
Step 18.4: In the Assignees pill popover, add a footer input with
+ Addbutton to push a new name intovalues.assignees. Mirror the existing pattern inNewTaskDialog.tsxlines 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.tsxwith 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.tsxto 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)
Task 20: 📎 attach footer pill (Edit mode only)
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
FormatPrefsfromlib/date.tsif 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
projectIdto 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.tsxbody. Keep only: header stat cards + a<TaskListView />for the body. The dialogs and detail sheet move intoTaskListView. 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
TaskItemtype out ofTaskRow.tsxto avoid losing it. Createsrc/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
KanbanBoardimport:
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
tasksnamespace ofen/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
toastnamespace 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 1–2 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.