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)
138 lines
4.5 KiB
TypeScript
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>
|
|
);
|
|
}
|