From c726a61ad669d9717512a541476d6cd85d14d691 Mon Sep 17 00:00:00 2001 From: Prashant Vasudevan <71649489+vprashrex@users.noreply.github.com> Date: Mon, 25 May 2026 11:03:56 +0530 Subject: [PATCH 1/5] Chore: Enhance spreadsheet state management with debouncing and schema validation --- .../assessment/SpreadsheetModalInner.tsx | 46 ++++++++++--- app/lib/assessment/constants.ts | 2 + app/lib/assessment/results.ts | 64 ++++++++++++++++++- app/lib/types/assessment.ts | 10 ++- 4 files changed, 109 insertions(+), 13 deletions(-) diff --git a/app/components/assessment/SpreadsheetModalInner.tsx b/app/components/assessment/SpreadsheetModalInner.tsx index cce3bd1..d657e4c 100644 --- a/app/components/assessment/SpreadsheetModalInner.tsx +++ b/app/components/assessment/SpreadsheetModalInner.tsx @@ -11,8 +11,12 @@ import { loadSpreadsheetState, persistSpreadsheetState, } from "@/app/lib/assessment/results"; +import { SPREADSHEET_STATE_DEBOUNCE_MS } from "@/app/lib/assessment/constants"; import type { UniverAPI } from "@/app/lib/types/assessment"; +// Univer command types: 0=COMMAND, 1=OPERATION, 2=MUTATION. Only mutations change state. +const UNIVER_MUTATION_TYPE = 2; + interface SpreadsheetModalInnerProps { runId: number; title: string; @@ -50,20 +54,42 @@ export default function SpreadsheetModalInner({ api.createUniverSheet(saved ?? buildSpreadsheetWorkbookData(headers, rows)); let debounceTimer: ReturnType | null = null; - const cmdDisposable = api.onCommandExecuted(() => { + let lastSerialized: string | null = null; + + const flushNow = () => { + if (debounceTimer) { + clearTimeout(debounceTimer); + debounceTimer = null; + } + try { + const snapshot = api.getActiveWorkbook()?.save(); + if (!snapshot) return; + const serialized = JSON.stringify(snapshot); + if (serialized === lastSerialized) return; // dedup unchanged saves + lastSerialized = serialized; + persistSpreadsheetState(runId, snapshot); + } catch { + // serialization failed or storage unavailable — keep in-memory state + } + }; + + const cmdDisposable = api.onCommandExecuted((info) => { + // Skip non-mutating commands (selection, scroll, focus, etc.) + if (info.type !== UNIVER_MUTATION_TYPE) return; if (debounceTimer) clearTimeout(debounceTimer); - debounceTimer = setTimeout(() => { - try { - const snapshot = api.getActiveWorkbook()?.save(); - if (snapshot) persistSpreadsheetState(runId, snapshot); - } catch { - // silently skip — storage quota exceeded or unavailable - } - }, 1500); + debounceTimer = setTimeout(flushNow, SPREADSHEET_STATE_DEBOUNCE_MS); }); + const handleVisibility = () => { + if (document.visibilityState === "hidden") flushNow(); + }; + window.addEventListener("beforeunload", flushNow); + document.addEventListener("visibilitychange", handleVisibility); + return () => { - if (debounceTimer) clearTimeout(debounceTimer); + window.removeEventListener("beforeunload", flushNow); + document.removeEventListener("visibilitychange", handleVisibility); + flushNow(); // flush pending edits on unmount cmdDisposable.dispose(); api.dispose?.(); univerRef.current = null; diff --git a/app/lib/assessment/constants.ts b/app/lib/assessment/constants.ts index 3ab1c72..79dc0f0 100644 --- a/app/lib/assessment/constants.ts +++ b/app/lib/assessment/constants.ts @@ -15,6 +15,8 @@ export const ASSESSMENT_CONFIG_VERSION_PAGE_SIZE = 8; export const RESULTS_POLL_INTERVAL_MS = 60_000; export const SPREADSHEET_STATE_STORAGE_PREFIX = "kaapi_sheet_state_"; +export const SPREADSHEET_STATE_SCHEMA_VERSION = 1; +export const SPREADSHEET_STATE_DEBOUNCE_MS = 800; export const SPREADSHEET_PREVIEW_ROW_LIMIT = 5000; export const MAX_DATASET_FILE_BYTES = 5 * 1024 * 1024; diff --git a/app/lib/assessment/results.ts b/app/lib/assessment/results.ts index 78a7e04..e0aa371 100644 --- a/app/lib/assessment/results.ts +++ b/app/lib/assessment/results.ts @@ -9,6 +9,7 @@ import { ACTIVE_ASSESSMENT_STATUSES, COMPLETED_ASSESSMENT_STATUSES, FAILED_ASSESSMENT_STATUSES, + SPREADSHEET_STATE_SCHEMA_VERSION, SPREADSHEET_STATE_STORAGE_PREFIX, } from "@/app/lib/assessment/constants"; @@ -74,17 +75,76 @@ export function spreadsheetStorageKey(runId: number): string { return `${SPREADSHEET_STATE_STORAGE_PREFIX}${runId}`; } +type SpreadsheetStateEnvelope = { + v: number; + ts: number; + data: object; +}; + export function loadSpreadsheetState(runId: number): object | null { try { const raw = localStorage.getItem(spreadsheetStorageKey(runId)); - return raw ? (JSON.parse(raw) as object) : null; + if (!raw) return null; + const parsed = JSON.parse(raw) as Partial; + if (parsed?.v !== SPREADSHEET_STATE_SCHEMA_VERSION || !parsed.data) { + // schema mismatch — discard so we don't hand stale shape to Univer + localStorage.removeItem(spreadsheetStorageKey(runId)); + return null; + } + return parsed.data; } catch { return null; } } +// Evict oldest spreadsheet-state entries until below `keep` count. +function evictOldestSpreadsheetStates(keep: number): void { + const entries: Array<{ key: string; ts: number }> = []; + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (!key?.startsWith(SPREADSHEET_STATE_STORAGE_PREFIX)) continue; + try { + const raw = localStorage.getItem(key); + if (!raw) continue; + const parsed = JSON.parse(raw) as Partial; + entries.push({ key, ts: parsed?.ts ?? 0 }); + } catch { + // malformed — treat as oldest so it gets dropped first + entries.push({ key, ts: 0 }); + } + } + entries.sort((a, b) => a.ts - b.ts); + const toDrop = Math.max(0, entries.length - keep); + for (let i = 0; i < toDrop; i++) { + localStorage.removeItem(entries[i].key); + } +} + export function persistSpreadsheetState(runId: number, data: object): void { - localStorage.setItem(spreadsheetStorageKey(runId), JSON.stringify(data)); + const envelope: SpreadsheetStateEnvelope = { + v: SPREADSHEET_STATE_SCHEMA_VERSION, + ts: Date.now(), + data, + }; + const key = spreadsheetStorageKey(runId); + const payload = JSON.stringify(envelope); + try { + localStorage.setItem(key, payload); + } catch (err) { + // Quota exceeded — drop oldest sheets (keep current) and retry once + const isQuota = + err instanceof DOMException && + (err.name === "QuotaExceededError" || + err.name === "NS_ERROR_DOM_QUOTA_REACHED"); + if (!isQuota) return; + try { + localStorage.removeItem(key); + evictOldestSpreadsheetStates(5); + localStorage.setItem(key, payload); + } catch { + // still failing — give up silently; in-memory state remains intact + } + } } type SpreadsheetCellEntry = { v: string | number; t: number; s?: object }; diff --git a/app/lib/types/assessment.ts b/app/lib/types/assessment.ts index c4ace61..11d66e8 100644 --- a/app/lib/types/assessment.ts +++ b/app/lib/types/assessment.ts @@ -272,9 +272,17 @@ export interface ResultsCounts { failed: number; } +export type UniverCommandInfo = { + id: string; + type?: number; + params?: unknown; +}; + export type UniverAPI = { dispose?: () => void; - onCommandExecuted: (cb: () => void) => { dispose: () => void }; + onCommandExecuted: (cb: (info: UniverCommandInfo) => void) => { + dispose: () => void; + }; getActiveWorkbook: () => { save: () => object } | null; createUniverSheet: (d: object) => void; }; From 3fa5056cf2bb2b30087aa88cbaa0dcf4ea9d2c12 Mon Sep 17 00:00:00 2001 From: Prashant Vasudevan <71649489+vprashrex@users.noreply.github.com> Date: Mon, 25 May 2026 11:21:13 +0530 Subject: [PATCH 2/5] Chore: Define SpreadsheetStateEnvelope type for improved state management --- app/lib/assessment/results.ts | 8 +------- app/lib/types/assessment.ts | 6 ++++++ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/app/lib/assessment/results.ts b/app/lib/assessment/results.ts index e0aa371..f73c31f 100644 --- a/app/lib/assessment/results.ts +++ b/app/lib/assessment/results.ts @@ -3,6 +3,7 @@ import type { AssessmentRun, ResultTone, ResultsCounts, + SpreadsheetStateEnvelope, StatusFilter, } from "@/app/lib/types/assessment"; import { @@ -75,19 +76,12 @@ export function spreadsheetStorageKey(runId: number): string { return `${SPREADSHEET_STATE_STORAGE_PREFIX}${runId}`; } -type SpreadsheetStateEnvelope = { - v: number; - ts: number; - data: object; -}; - export function loadSpreadsheetState(runId: number): object | null { try { const raw = localStorage.getItem(spreadsheetStorageKey(runId)); if (!raw) return null; const parsed = JSON.parse(raw) as Partial; if (parsed?.v !== SPREADSHEET_STATE_SCHEMA_VERSION || !parsed.data) { - // schema mismatch — discard so we don't hand stale shape to Univer localStorage.removeItem(spreadsheetStorageKey(runId)); return null; } diff --git a/app/lib/types/assessment.ts b/app/lib/types/assessment.ts index 11d66e8..b26b6ab 100644 --- a/app/lib/types/assessment.ts +++ b/app/lib/types/assessment.ts @@ -287,6 +287,12 @@ export type UniverAPI = { createUniverSheet: (d: object) => void; }; +export type SpreadsheetStateEnvelope = { + v: number; + ts: number; + data: object; +}; + export interface AssessmentResultsPreview { runId: number; title: string; From 05299810f4b87142404b450dd49aa597840ef7bb Mon Sep 17 00:00:00 2001 From: Prashant Vasudevan <71649489+vprashrex@users.noreply.github.com> Date: Tue, 2 Jun 2026 09:22:33 +0530 Subject: [PATCH 3/5] feat: Enhance assessment configuration and saved config cards with latest model information - Added `latestModelByConfig` to `AssessmentConfiguration` and `SavedConfigs` components to track the latest model for each config. - Updated `SavedConfigCard` to display the latest model information if available. - Introduced `useLatestConfigModels` hook to fetch and manage the latest model data for each config. - Created a new `ExpandIcon` component for UI consistency. - Implemented `FormulaInput` component for enhanced formula editing with at-mention support. - Refactored assessment results handling to include new post-processing configurations and improved data normalization. - Added new types for L1 configurations and post-processing settings in the assessment types. - Updated constants and utility functions to support new features and improve code maintainability. --- app/(main)/assessment/page.tsx | 35 +- .../assessment/results/[runId]/page.tsx | 109 +++++ .../assessment/ColumnMapperStep.tsx | 22 +- app/components/assessment/ConfigPanel.tsx | 50 +- app/components/assessment/EvaluationsTab.tsx | 278 +++++------ app/components/assessment/L1FiltersStep.tsx | 311 +++++++++++++ .../assessment/PostProcessingPanel.tsx | 434 ++++++++++++++++++ .../assessment/PostProcessingStep.tsx | 393 ++++++++++++++++ .../assessment/PromptAndConfigStep.tsx | 30 +- app/components/assessment/ReviewStep.tsx | 4 +- .../assessment/SpreadsheetModal.tsx | 26 -- ...heetModalInner.tsx => SpreadsheetView.tsx} | 44 +- .../prompt-config/AssessmentConfiguration.tsx | 4 + .../prompt-config/SavedConfigCard.tsx | 27 +- .../assessment/prompt-config/SavedConfigs.tsx | 9 +- app/components/icons/common/ExpandIcon.tsx | 23 + app/components/icons/index.tsx | 1 + app/components/shared/FormulaInput.tsx | 181 ++++++++ app/hooks/useAssessmentResults.ts | 131 +++--- app/hooks/useLatestConfigModels.ts | 60 +++ app/lib/assessment/constants.ts | 86 +++- app/lib/assessment/results.ts | 45 +- app/lib/hooks/useAtMention.ts | 192 ++++++++ app/lib/types/assessment.ts | 121 ++++- 24 files changed, 2299 insertions(+), 317 deletions(-) create mode 100644 app/(main)/assessment/results/[runId]/page.tsx create mode 100644 app/components/assessment/L1FiltersStep.tsx create mode 100644 app/components/assessment/PostProcessingPanel.tsx create mode 100644 app/components/assessment/PostProcessingStep.tsx delete mode 100644 app/components/assessment/SpreadsheetModal.tsx rename app/components/assessment/{SpreadsheetModalInner.tsx => SpreadsheetView.tsx} (64%) create mode 100644 app/components/icons/common/ExpandIcon.tsx create mode 100644 app/components/shared/FormulaInput.tsx create mode 100644 app/hooks/useLatestConfigModels.ts create mode 100644 app/lib/hooks/useAtMention.ts diff --git a/app/(main)/assessment/page.tsx b/app/(main)/assessment/page.tsx index a0ac358..076b684 100644 --- a/app/(main)/assessment/page.tsx +++ b/app/(main)/assessment/page.tsx @@ -1,6 +1,13 @@ "use client"; -import { Suspense, useCallback, useMemo, useRef, useState } from "react"; +import { + Suspense, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { useRouter } from "next/navigation"; import Loader from "@/app/components/Loader"; import { useToast } from "@/app/components/Toast"; @@ -15,6 +22,8 @@ import type { AssessmentTab, AssessmentTabId, ConfigSelection, + L1Config, + PostProcessingConfig, SchemaProperty, } from "@/app/lib/types/assessment"; import PageLayout from "@/app/components/assessment/PageLayout"; @@ -93,6 +102,9 @@ function PageContent() { const [systemInstruction, setSystemInstruction] = useState(""); const [outputSchema, setOutputSchema] = useState([]); const [configs, setConfigs] = useState([]); + const [l1Config, setL1Config] = useState(null); + const [postProcessingConfig, setPostProcessingConfig] = + useState(null); const handleForbidden = useCallback( (options?: { notify?: boolean }) => { @@ -141,6 +153,10 @@ function PageContent() { [setDataset], ); + useEffect(() => { + setL1Config(null); + }, [datasetId]); + const outputSchemaJson = useMemo( () => schemaToJsonSchema(outputSchema), [outputSchema], @@ -190,6 +206,8 @@ function PageContent() { config_id, config_version, })), + l1_config: l1Config ?? null, + post_processing_config: postProcessingConfig ?? null, }), }); @@ -202,6 +220,8 @@ function PageContent() { setPromptTemplate(""); setOutputSchema([]); setConfigs([]); + setL1Config(null); + setPostProcessingConfig(null); setActiveTab("results"); } catch (error) { if (handleForbiddenError(error, handleForbiddenWithNotify)) return; @@ -218,8 +238,10 @@ function PageContent() { datasetId, experimentName, handleForbiddenWithNotify, + l1Config, outputSchema, outputSchemaJson, + postProcessingConfig, promptTemplate, activeKey, systemInstruction, @@ -238,6 +260,8 @@ function PageContent() { promptTemplate, outputSchema, configs, + l1Config, + postProcessingConfig, }; const hasDataset = !!datasetId && columns.length > 0; @@ -272,7 +296,9 @@ function PageContent() { const effectiveCompletedConfigSteps = useMemo(() => { const merged = new Set(completedConfigSteps); if (hasMapperSelection) merged.add(1); - if (canReachReview) merged.add(2); + if (hasMapperSelection) merged.add(2); // L1 Filters is optional and always passable + if (canReachReview) merged.add(3); + if (canReachReview) merged.add(4); // Post Processing is optional and always passable return merged; }, [canReachReview, completedConfigSteps, hasMapperSelection]); @@ -299,10 +325,12 @@ function PageContent() { completedSteps: effectiveCompletedConfigSteps, configStep, configs, + datasetId, experimentName, formState, hasDataset, isSubmitting, + l1Config, outputSchema, systemInstruction, promptTemplate, @@ -312,9 +340,12 @@ function PageContent() { setConfigStep, setConfigs, setExperimentName, + setL1Config, setOutputSchema, setSystemInstruction, setPromptTemplate, + postProcessingConfig, + setPostProcessingConfig, submitBlockerMessage, onSubmit: handleSubmit, onStepComplete: handleConfigNext, diff --git a/app/(main)/assessment/results/[runId]/page.tsx b/app/(main)/assessment/results/[runId]/page.tsx new file mode 100644 index 0000000..61de3c7 --- /dev/null +++ b/app/(main)/assessment/results/[runId]/page.tsx @@ -0,0 +1,109 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useParams, useSearchParams } from "next/navigation"; +import dynamic from "next/dynamic"; +import Loader from "@/app/components/Loader"; +import { useToast } from "@/app/components/Toast"; +import { useAuth } from "@/app/lib/context/AuthContext"; +import { apiFetch } from "@/app/lib/apiClient"; +import { jsonResultsToTableData } from "@/app/lib/assessment/results"; +import { SPREADSHEET_PREVIEW_ROW_LIMIT } from "@/app/lib/assessment/constants"; + +const SpreadsheetView = dynamic( + () => import("@/app/components/assessment/SpreadsheetView"), + { + ssr: false, + loading: () => ( +
+ +
+ ), + }, +); + +export default function AssessmentResultsPage() { + const params = useParams<{ runId: string }>(); + const searchParams = useSearchParams(); + const toast = useToast(); + const { apiKeys, isAuthenticated, isHydrated } = useAuth(); + const apiKey = apiKeys[0]?.key ?? ""; + + const runId = Number(params?.runId); + const title = searchParams.get("title") ?? `Run ${runId}`; + + const [headers, setHeaders] = useState(null); + const [rows, setRows] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + if (!isHydrated) return; + if (!isAuthenticated) { + setError("You must be signed in to view this run."); + return; + } + if (!Number.isFinite(runId) || runId <= 0) { + setError("Invalid run id."); + return; + } + + let cancelled = false; + (async () => { + try { + const json = await apiFetch< + { data?: Record[] } | Record[] + >(`/api/assessment/runs/${runId}/results?export_format=json`, apiKey); + const results: Record[] = Array.isArray(json) + ? json + : json.data || []; + const table = jsonResultsToTableData(results, { + rowLimit: SPREADSHEET_PREVIEW_ROW_LIMIT, + }); + if (cancelled) return; + if (results.length > SPREADSHEET_PREVIEW_ROW_LIMIT) { + toast.warning( + `Preview capped at ${SPREADSHEET_PREVIEW_ROW_LIMIT} rows. Download CSV for full data.`, + ); + } + setHeaders(table.headers); + setRows(table.rows); + } catch (err) { + if (cancelled) return; + const msg = + err instanceof Error ? err.message : "Failed to load results"; + setError(msg); + toast.error(msg); + } + })(); + + return () => { + cancelled = true; + }; + }, [apiKey, isAuthenticated, isHydrated, runId, toast]); + + if (error) { + return ( +
+

{error}

+
+ ); + } + + if (!headers || !rows) { + return ( +
+ +
+ ); + } + + return ( + + ); +} diff --git a/app/components/assessment/ColumnMapperStep.tsx b/app/components/assessment/ColumnMapperStep.tsx index 0fa075d..d57c752 100644 --- a/app/components/assessment/ColumnMapperStep.tsx +++ b/app/components/assessment/ColumnMapperStep.tsx @@ -47,14 +47,17 @@ export default function ColumnMapperStep({ next[index] = { role, - attachmentType: current?.attachmentType || "image", + attachmentType: current?.attachmentType || "mixed", attachmentFormat: current?.attachmentFormat || "url", }; return next; }); }; - const updateAttachmentType = (index: number, type: "image" | "pdf") => { + const updateAttachmentType = ( + index: number, + type: "image" | "pdf" | "mixed", + ) => { setColumnConfigs((prev) => { const next = [...prev]; next[index] = { @@ -218,19 +221,26 @@ export default function ColumnMapperStep({ Attachment Type