ui: simplify import feedback and shorten waiting status

This commit is contained in:
amosblomqvist
2026-10-23 06:14:33 +03:00
parent a67366005a
commit c36c1c6b4d
6 changed files with 189 additions and 76 deletions
+2 -2
View File
@@ -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({
/>
</div>
<NotesImportResults {...notesImport} />
<NotesImportErrors errors={notesImport.errors} />
{/* 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
+2 -2
View File
@@ -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({
</Hint>
</div>
</div>
<NotesImportResults {...notesImport} />
<NotesImportErrors errors={notesImport.errors} />
{costs && costs.runs.length > 0 && <GraphCostControl costs={costs} />}
</div>
+14 -48
View File
@@ -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<NotesImpo
return parseNotesImportResults(body, files);
}
/** Files live only with the latest selection. Retry submits exactly one captured
* File, not its basename; no automatic retries or persistent import history. */
/** Only error text survives a request; selected Files stay request-local. */
export function useNotesImport(callbacks: { onStart?: () => void; onFinish?: () => void } = {}) {
const [rows, setRows] = useState<NotesImportRow[]>([]);
const [errors, setErrors] = useState<string[]>([]);
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<ReturnType<typeof useNotesImport>, "rows" | "importing" | "retry" | "dismiss">) {
if (rows.length === 0) return null;
return (
<section aria-label="Notes import results" aria-live="polite" className="w-80 max-w-full max-h-[40vh] overflow-y-auto rounded-xl border bg-card/95 p-3 text-xs shadow-lg backdrop-blur">
<ul className="space-y-3">
{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 <li key={i} className="min-w-0 break-words">
<p className="font-medium">{row.file.name}</p>
<p className="text-muted-foreground">{message}</p>
{r && !r.ok && <>
<p className="whitespace-pre-wrap text-muted-foreground">{r.error}</p>
{r.code && <p className="text-muted-foreground">{r.code}{r.provider ? ` — ${r.provider}` : ""}{r.missing?.length ? ` — ${r.missing.join(", ")}` : ""}{r.slots?.length ? ` — ${r.slots.join(", ")}` : ""}</p>}
<button type="button" disabled={importing} onClick={() => retry(i)} className="mt-1 rounded-lg px-2 py-1 text-muted-foreground hover:bg-accent disabled:opacity-50">Retry</button>
</>}
</li>;
})}
</ul>
<button type="button" disabled={importing} onClick={dismiss} className="mt-3 rounded-lg px-2 py-1 text-muted-foreground hover:bg-accent disabled:opacity-50">Dismiss results</button>
</section>
);
export function NotesImportErrors({ errors }: { errors: readonly string[] }) {
return <>{errors.map((error, i) => (
<p key={i} role="alert" className="max-w-full whitespace-pre-wrap break-words text-xs text-red-400">{error}</p>
))}</>;
}
+2 -2
View File
@@ -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)}`;
}