learning_ai_notes/web/src/components/ExtractedTasksPanel.tsx
saravanakumardb1 2408f43426 feat(web/ui5+ui7): migrate 12 components to @bytelyst/ui primitives
Finishes UI5 and kicks off UI7 by migrating the remaining form-heavy
components plus the note-detail right-rail panels. Drops legacy class
matches from 92 → 67 (-25) and raw interactive controls from 38 → 25
(-13). Ratchet baseline updated to the new floor.

Components migrated:

UI5 finish:
- NoteEditor.tsx — surface-card wrapper → Card, title input → Input,
  Tiptap editor className updated to use border + bg classes instead
  of input-shell. Toolbar buttons left as raw (intentional, tightly
  styled icon controls).
- SmartActionsPanel.tsx — result panel surface-muted → Tailwind
  bg-[var(--nl-surface-muted)] utility.
- ArtifactPanel.tsx — section→Card, badge→Badge, all three input-shell
  inputs/selects/textareas→Input/Select/Textarea, surface-muted form
  shell + per-artifact row → Tailwind bg-utility, raw <button> Open
  → Button.
- CommandPalette.tsx — surface-card command sheet → Tailwind layered
  classes, search input → Input (now ref-forwarded), kind badge → Badge.

UI7 component pass:
- MetadataPanel.tsx — section→Card, tag badge→Badge.
- LinkedNotesPanel.tsx — section→Card, surface-muted link row →
  Tailwind bg-utility with hover state.
- PalaceStats.tsx — section→Card, inline styles → Tailwind utilities.
- ExtractedTasksPanel.tsx — surface-muted row → Tailwind.
- NoteVersionsPanel.tsx — all three section/surface-card variants →
  Card + raw button → preserved (interactive disclosure).
- Pagination.tsx — raw <button> Previous/Next → Button, surface-muted
  → built-in secondary variant.
- TaskReviewPanel.tsx — full migration: section→Card, badge→Badge,
  input-shell + textarea + raw button → Input/Textarea/Button.
- SurveyBanner.tsx — survey answer input-shell → Input.

Adapter changes:
- web/src/components/ui/Primitives.tsx — Input and Textarea now use
  React.forwardRef so callers like CommandPalette can attach refs.

Verified:
- pnpm --filter @notelett/web run typecheck: passes
- pnpm --filter @notelett/web test: 96/96 still pass
- pnpm run audit:ui:ratchet: at new baseline (25/67/0/0)
- pnpm run audit:ui: legacy class matches now in dashboard / search /
  workspaces / notes-detail / palace / chat pages (UI6/UI7 page targets)
2026-05-23 01:33:48 -07:00

138 lines
4.5 KiB
TypeScript

"use client";
import { useCallback, useMemo, useState } from "react";
import { extractSuggestedTasks } from "@/lib/extraction-client";
import { StateNotice } from "@/components/StateNotice";
import { Button, Card } from "@/components/ui/Primitives";
import { createNoteTask } from "@/lib/notes-client";
import { toast } from "@/lib/toast";
import { getEmptyState, toUserFacingState, type UserFacingState } from "@/lib/user-facing-states";
import type { NoteTask } from "@/lib/types";
export function ExtractedTasksPanel({
noteId,
workspaceId,
noteBody,
persistedTasks,
onTaskAccepted,
}: {
noteId: string;
workspaceId: string;
noteBody: string;
persistedTasks: NoteTask[];
onTaskAccepted: () => void;
}) {
const [proposals, setProposals] = useState<NoteTask[]>([]);
const [scanning, setScanning] = useState(false);
const [acceptingId, setAcceptingId] = useState<string | null>(null);
const [noticeState, setNoticeState] = useState<UserFacingState | null>(null);
const persistedTitles = useMemo(
() => new Set(persistedTasks.map((t) => t.title.trim().toLowerCase())),
[persistedTasks],
);
const handleScan = useCallback(async () => {
setScanning(true);
setNoticeState(null);
try {
const extracted = await extractSuggestedTasks(noteBody);
const filtered = extracted.filter((t) => !persistedTitles.has(t.title.trim().toLowerCase()));
setProposals(filtered);
if (filtered.length === 0) {
setNoticeState(getEmptyState("extraction", "suggested tasks"));
}
} catch (e) {
const state = toUserFacingState(e, "extraction");
setNoticeState(state);
toast.error(state.message);
} finally {
setScanning(false);
}
}, [noteBody, persistedTitles]);
const handleAccept = useCallback(
async (task: NoteTask) => {
setAcceptingId(task.id);
try {
await createNoteTask({
id: crypto.randomUUID(),
workspaceId,
noteId,
title: task.title,
source: "extracted",
});
setProposals((p) => p.filter((x) => x.id !== task.id));
toast.success("Task added");
onTaskAccepted();
} catch (e) {
const state = toUserFacingState(e, "backend");
setNoticeState(state);
toast.error(state.message);
} finally {
setAcceptingId(null);
}
},
[noteId, workspaceId, onTaskAccepted],
);
const handleDismiss = useCallback((taskId: string) => {
setProposals((p) => p.filter((x) => x.id !== taskId));
}, []);
return (
<Card style={{ padding: "var(--nl-space-5)", display: "grid", gap: "var(--nl-space-3)" }}>
<div style={{ display: "flex", justifyContent: "space-between", gap: "var(--nl-space-3)", alignItems: "center", flexWrap: "wrap" }}>
<div style={{ fontWeight: 700 }}>Suggested tasks (AI)</div>
<Button
type="button"
variant="secondary"
disabled={scanning}
loading={scanning}
onClick={() => void handleScan()}
>
Scan note for tasks
</Button>
</div>
<p style={{ margin: 0, color: "var(--nl-text-secondary)", fontSize: "var(--nl-fs-sm)" }}>
Runs extraction on demand. Accept adds a backend task; dismiss only hides the suggestion for this session.
</p>
{noticeState ? (
<StateNotice
state={noticeState}
compact
onAction={noticeState.actionLabel ? () => void handleScan() : undefined}
/>
) : null}
{proposals.length === 0 ? null : (
<ul style={{ listStyle: "none", margin: 0, padding: 0, display: "grid", gap: "var(--nl-space-2)" }}>
{proposals.map((task) => (
<li key={task.id} className="flex flex-wrap items-center justify-between gap-2 rounded-[var(--nl-radius-sm)] bg-[color:var(--nl-surface-muted)] p-3">
<span>{task.title}</span>
<span style={{ display: "flex", gap: 8 }}>
<Button
type="button"
size="sm"
disabled={acceptingId === task.id}
loading={acceptingId === task.id}
onClick={() => void handleAccept(task)}
>
Accept
</Button>
<Button
type="button"
variant="secondary"
size="sm"
onClick={() => handleDismiss(task.id)}
>
Dismiss
</Button>
</span>
</li>
))}
</ul>
)}
</Card>
);
}