From c36c1c6b4de9ce004587b67c9f05246dd55375e2 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Fri, 23 Oct 2026 06:14:33 +0300 Subject: [PATCH] ui: simplify import feedback and shorten waiting status --- components/graph-view.tsx | 4 +- components/minimal-home.tsx | 4 +- components/notes-import.tsx | 62 +++---------- components/sidebar.tsx | 4 +- tests/notes-import.test.tsx | 170 ++++++++++++++++++++++++++++++---- tests/render-sidebar.test.tsx | 21 ++++- 6 files changed, 189 insertions(+), 76 deletions(-) diff --git a/components/graph-view.tsx b/components/graph-view.tsx index 379294e..606b567 100644 --- a/components/graph-view.tsx +++ b/components/graph-view.tsx @@ -64,7 +64,7 @@ */ import { useCallback, useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; -import { NotesImportResults, useNotesImport } from "@/components/notes-import.tsx"; +import { NotesImportErrors, useNotesImport } from "@/components/notes-import.tsx"; import Link from "next/link"; import { FileTextIcon, ListTreeIcon, Loader2Icon, SearchIcon, SettingsIcon, UploadIcon, WaypointsIcon } from "lucide-react"; import { incrementalLayout, propositionSessionHref, type GraphCostSummary, type GraphNode, type GraphSnapshot } from "@/src/core"; @@ -1605,7 +1605,7 @@ export function GraphView({ /> - + {/* Top-right tail (owner, 2026-09-20): the pipeline cost number — just a number, hover-highlighted, dropdown breakdown — then the running-pipeline pills under it. Lives BELOW the actions diff --git a/components/minimal-home.tsx b/components/minimal-home.tsx index 569030b..c51baa2 100644 --- a/components/minimal-home.tsx +++ b/components/minimal-home.tsx @@ -22,7 +22,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { ListTreeIcon, UploadIcon } from "lucide-react"; import { ChainTableOverlay } from "@/components/chain-table.tsx"; import { GraphCostControl } from "@/components/graph-view.tsx"; -import { NotesImportResults, useNotesImport } from "@/components/notes-import.tsx"; +import { NotesImportErrors, useNotesImport } from "@/components/notes-import.tsx"; import { Hint } from "@/components/ui/hint"; import type { ChatSummary, GraphCostSummary, GraphSnapshot } from "@/src/core"; @@ -194,7 +194,7 @@ export function MinimalHome({ - + {costs && costs.runs.length > 0 && } diff --git a/components/notes-import.tsx b/components/notes-import.tsx index 41c6d59..0d42203 100644 --- a/components/notes-import.tsx +++ b/components/notes-import.tsx @@ -15,11 +15,6 @@ export interface NotesImportResult { provider?: string; slots?: string[]; } -export interface NotesImportRow { - file: File; - pending: boolean; - result?: NotesImportResult; -} /** Validate the entire response before applying any row: a malformed index * must never attribute another file's success to this file (same names included). */ @@ -56,35 +51,26 @@ export async function postNotesImport(files: readonly File[]): Promise void; onFinish?: () => void } = {}) { - const [rows, setRows] = useState([]); + const [errors, setErrors] = useState([]); const [importing, setImporting] = useState(false); const pending = useRef(false); - const submit = async (files: File[], retryIndex?: number) => { + const submit = async (files: File[]) => { if (pending.current || files.length === 0) return; pending.current = true; setImporting(true); - if (retryIndex === undefined) setRows(files.map((file) => ({ file, pending: true }))); - else setRows((prev) => prev.map((row, i) => i === retryIndex ? { file: row.file, pending: true } : row)); + setErrors([]); try { callbacks.onStart?.(); const results = await postNotesImport(files); - setRows((prev) => { - const next = [...prev]; - for (const result of results) { - const index = retryIndex ?? result.requestIndex; - next[index] = { file: files[result.requestIndex]!, pending: false, result }; - } - return next; - }); + setErrors(results + .filter((result) => !result.ok) + .sort((a, b) => a.requestIndex - b.requestIndex) + .map((r) => `${r.filePath}: ${r.error}${r.code ? `\n${r.code}${r.provider ? ` — ${r.provider}` : ""}${r.missing?.length ? ` — ${r.missing.join(", ")}` : ""}${r.slots?.length ? ` — ${r.slots.join(", ")}` : ""}` : ""}`)); } catch (error) { const message = error instanceof Error ? error.message : String(error); - setRows((prev) => prev.map((row, i) => retryIndex === undefined || i === retryIndex ? { - file: row.file, pending: false, - result: { filePath: row.file.name, requestIndex: retryIndex === undefined ? i : 0, ok: false, outcome: "failed", error: message }, - } : row)); + setErrors(files.map((file) => `${file.name}: ${message}`)); } finally { pending.current = false; setImporting(false); @@ -92,33 +78,13 @@ export function useNotesImport(callbacks: { onStart?: () => void; onFinish?: () } }; return { - rows, importing, + errors, importing, onImportFiles: (files: FileList | null) => { if (files) void submit(Array.from(files)); }, - retry: (index: number) => { if (rows[index]) void submit([rows[index].file], index); }, - dismiss: () => { if (!pending.current) setRows([]); }, }; } -export function NotesImportResults({ rows, importing, retry, dismiss }: Pick, "rows" | "importing" | "retry" | "dismiss">) { - if (rows.length === 0) return null; - return ( -
-
    - {rows.map((row, i) => { - const r = row.result; - const message = row.pending ? "Importing…" : r?.outcome === "completed" ? `Import complete — ${r.stats!.nodes} new propositions, ${r.stats!.chains ?? 0} new chains.` : r?.outcome === "partial" ? "Some parts completed; others failed. Saved changes were kept." : r?.outcome === "aborted" ? "Import stopped. Any changes already saved were kept." : r?.outcome === "busy" ? "This file is already being imported. Try again when it finishes." : "Import did not complete. Any changes already saved were kept."; - return
  • -

    {row.file.name}

    -

    {message}

    - {r && !r.ok && <> -

    {r.error}

    - {r.code &&

    {r.code}{r.provider ? ` — ${r.provider}` : ""}{r.missing?.length ? ` — ${r.missing.join(", ")}` : ""}{r.slots?.length ? ` — ${r.slots.join(", ")}` : ""}

    } - - } -
  • ; - })} -
- -
- ); +export function NotesImportErrors({ errors }: { errors: readonly string[] }) { + return <>{errors.map((error, i) => ( +

{error}

+ ))}; } diff --git a/components/sidebar.tsx b/components/sidebar.tsx index 1f21140..dfcb898 100644 --- a/components/sidebar.tsx +++ b/components/sidebar.tsx @@ -213,7 +213,7 @@ export function SidebarToggle() { * phases only advance (all sessions end in Teaching — phase-as-signal was * decoration, now retired app-wide), and question-pending is the dominant * RESTING state of real sessions — a resting state has no scan value, so - * the meta line's "Waiting for your answer" text carries it. Idle is the + * the meta line's "Waiting for input" text carries it. Idle is the * absence of a signal, by design. */ function SessionStatus({ busy }: { busy: boolean }) { @@ -250,7 +250,7 @@ export function timeAgo(iso: string, now = new Date()): string { * ever wanted. */ function sessionMeta(chat: ChatListItem): string { if (chat.compacting) return `Compacting context · ${timeAgo(chat.updatedAt)}`; - if (chat.pendingQuestion) return `Waiting for your answer · ${timeAgo(chat.updatedAt)}`; + if (chat.pendingQuestion) return `Waiting for input · ${timeAgo(chat.updatedAt)}`; if ((chat.kind ?? "teach") === "practice" && chat.pendingProblem) { return `Problem waiting · ${timeAgo(chat.updatedAt)}`; } diff --git a/tests/notes-import.test.tsx b/tests/notes-import.test.tsx index b66d093..602e6c0 100644 --- a/tests/notes-import.test.tsx +++ b/tests/notes-import.test.tsx @@ -1,10 +1,44 @@ -import assert from "node:assert/strict"; -import { after, it } from "node:test"; -import { renderToStaticMarkup } from "react-dom/server"; -import { NotesImportResults, parseNotesImportResults, postNotesImport, type NotesImportResult } from "../components/notes-import.tsx"; +import React, { act } from "react"; +(globalThis as Record).React = React; +import assert from "node:assert/strict"; +import { afterEach, it } from "node:test"; +import { createRoot, type Root } from "react-dom/client"; +import { renderToStaticMarkup } from "react-dom/server"; +import { parseHTML } from "linkedom"; +import { NotesImportErrors, parseNotesImportResults, postNotesImport, useNotesImport, type NotesImportResult } from "../components/notes-import.tsx"; + +// Real hook/React state, isolated DOM and wholly stubbed import transport. +const dom = parseHTML(""); +Object.assign(globalThis, { window: dom.window, document: dom.document, IS_REACT_ACT_ENVIRONMENT: true }); const originalFetch = globalThis.fetch; -after(() => { globalThis.fetch = originalFetch; }); +let root: Root | undefined; +let host: HTMLDivElement | undefined; +let current: ReturnType; +let starts = 0; +let finishes = 0; +async function mount() { + starts = 0; + finishes = 0; + host = document.createElement("div"); + document.body.appendChild(host); + root = createRoot(host); + function Harness() { + current = useNotesImport({ onStart: () => { starts++; }, onFinish: () => { finishes++; } }); + return ; + } + await act(async () => { root!.render(); }); +} +async function submit(selected: File[] | null = [...files]) { + await act(async () => { current.onImportFiles(selected as unknown as FileList | null); }); +} +afterEach(async () => { + await act(async () => { root?.unmount(); }); + root = undefined; + host?.remove(); + host = undefined; + globalThis.fetch = originalFetch; +}); const files = [new File(["one"], "same.md"), new File(["two"], "same.md")]; const completed = (requestIndex = 0): NotesImportResult => ({ requestIndex, filePath: "same.md", ok: true, outcome: "completed", stats: { nodes: 0, chains: 0 } }); @@ -23,6 +57,11 @@ for (const [name, results] of [ ["conflicting ok", [{ ...completed(), ok: false }, completed(1)]], ["invalid stats", [{ ...completed(), stats: { nodes: "bad" } }, completed(1)]], ["invalid details", [{ ...completed(), slots: [42] }, completed(1)]], + ["invalid provider", [{ ...completed(), provider: 42 }, completed(1)]], + ["invalid code", [{ ...completed(), code: 42 }, completed(1)]], + ["invalid missing", [{ ...completed(), missing: [42] }, completed(1)]], + ["invalid error", [{ ...completed(), error: 42 }, completed(1)]], + ["missing failure error", [{ ...completed(), ok: false, outcome: "failed" }, completed(1)]], ] as const) it(`malformed response (${name}) is rejected for retry`, () => { assert.throws(() => parseNotesImportResults({ results }, files), /Please retry/); }); @@ -47,24 +86,115 @@ for (const kind of ["network", "non-JSON", "non-2xx", "malformed"] as const) it( await assert.rejects(postNotesImport([files[0]!])); }); -for (const outcome of ["completed", "failed", "partial", "aborted", "busy"] as const) it(`renders ${outcome}, escaped filename/details, Retry and one Dismiss action`, () => { +for (const outcome of ["completed", "failed", "partial", "aborted", "busy"] as const) it(`${outcome} leaves only escaped raw failure text, without results or controls`, async () => { const file = new File(["notes"], "