# 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//` 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; export type NewTaskAttachment = InferInsertModel; ``` - [ ] **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 { 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 { 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 ; } const visible = assignees.slice(0, 2); const overflow = assignees.length - visible.length; return (
{visible.map((name, i) => ( 0 && '-ml-2', )} > {initials(name)} ))} {overflow > 0 && ( +{overflow} )}
{assignees.join(', ')}
); } ``` - [ ] **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 ( {t(conf.labelKey)} ); } ``` - [ ] **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 ( ); } ``` - [ ] **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 ( {/* Sticky header */}
{task.clientName ?? '—'} › {task.projectName ?? '—'}
{task.title}
{/* Scrolling body */}
{/* Properties + description + comments go here in later tasks */}
{/* Sticky composer */}
{/* Composer wired in Task 13 */}
); } ``` - [ ] **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 `
` body: ```tsx
{task.clientName && {task.clientName}} {task.clientName && task.projectName && } {task.projectName && {task.projectName}}
onEdit(task)}> {t('common.edit')} onDelete(task.id)} className="text-destructive focus:text-destructive"> {t('common.delete')}
{task.title}
``` - [ ] **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
{task.dueDate ? formatDueDate(task.dueDate, prefs) : } {formatRelative(task.createdAt ?? Date.now())}
``` - [ ] **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 (
{label}
{children}
); } ``` - [ ] **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 `` cells, add a full-width files row inside the same grid (use a `col-span-2` container): ```tsx
{t('tasks.attachments')}
{(attachments.list.data ?? []).map((a) => ( attachments.open.mutate({ id: a.id })} onDelete={() => attachments.remove.mutate({ id: a.id })} /> ))}
``` - [ ] **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
{t('tasks.description')}
{task.description ? (
{task.description}
) : (
{t('tasks.noDescription')}
)}
{t('tasks.comments')} · {comments?.length ?? 0}
{(comments ?? []).map((c) => (
{c.author.split(/\s+/).slice(0, 2).map((w) => w[0]?.toUpperCase() ?? '').join('')}
{c.author} {formatRelative(c.createdAt)}
{c.content}
))}
``` - [ ] **Step 13.4:** Replace the sticky composer footer: ```tsx
addComment.mutate({ taskId: task.id, author: 'Me', content: text })} />
``` - [ ] **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'; // ... { 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 `` chip in a Popover: ```tsx {(['high', 'medium', 'low'] as const).map((p) => ( ))} ``` - [ ] **Step 15.3:** Same pattern for `` with `todo / in_progress / done`: ```tsx {(['todo', 'in_progress', 'done'] as const).map((s) => ( ))} ``` - [ ] **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; 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({ ...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 ( {mode === 'create' ? t('tasks.newTask') : t('tasks.editTask')} ⌘+Enter
{ if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') handleSubmit(e); }}>
setValues((v) => ({ ...v, title: e.target.value }))} />