diff --git a/docs/ARCHIVE.md b/docs/ARCHIVE.md index f0c1e28..88c0386 100644 --- a/docs/ARCHIVE.md +++ b/docs/ARCHIVE.md @@ -3,6 +3,7 @@ These documents are historical — they describe plans that have been executed, reviews whose findings have been addressed, or assessments of a prior state of the codebase. They are retained for context and decision history. **For current documentation, see:** +- `strategy/2026-09-24-usefulness-first-strategy.md` — Living forward plan (useful first, unique second) - `MAJOR_CHANGES.md` — Running log of substantive changes - `DEPLOY.md` — Production deployment guide - `ENV.md` — Environment variable reference diff --git a/docs/formualizer-gaps.md b/docs/formualizer-gaps.md index eb25646..5b75486 100644 --- a/docs/formualizer-gaps.md +++ b/docs/formualizer-gaps.md @@ -1,26 +1,27 @@ # Formualizer Gaps & Issues for SmartSht > Compiled from: GitHub issues, source analysis, SmartSht integration behavior, and formualizer.dev docs. -> SmartSht uses `@ocean8219/formualizer@0.7.2` — latest is `0.8.4`. +> SmartSht uses `@ocean8219/formualizer@^0.9.3` (aligned with upstream 0.9.3 as of 2026-09-11). +> Last doc refresh: 2026-09-25. --- -## 1. Version Gap (0.7.2 → 0.8.4) +## 1. Version Status (current: 0.9.3) -SmartSht is 3 minor versions behind. The following was fixed/added in 0.7.3–0.8.4 that SmartSht is missing: +SmartSht is on **0.9.3**. The earlier 0.7.2 → 0.8.4 upgrade path is complete. -- Multiple structural edit undo bugs fixed -- Dependency graph edge-drop fixes (silent stale values) -- Database function empty-text handling fix -- SheetPort GIL deadlock fix (Python, but Rust core changes) -- FormulaPlane span evaluation improvements -- Semantic reference consolidation hardening +Notable 0.8.x–0.9.x gains already in tree: -**Recommendation:** Upgrade to `@ocean8219/formualizer@^0.8.4` +- Structural edit / dependency-graph hardening from 0.7.3–0.8.4 +- 0.9.0 circular-cell fixed-point retention (large recalc wins for converged cycles) +- 0.9.2 cache-only XLSX recalc APIs (opt-in; SmartSht does not use these yet) +- 0.9.3 broader date/time text parsing (partial #290), COUNTIF/COUNTBLANK blank arithmetic (partial #285), bulk-ingest dependency fixes, formula-assignment failure reporting + +**Recommendation:** Stay on `^0.9.3` for now. Do not blind-upgrade further until golden-set tests and import-honesty warnings are green in CI. Re-evaluate when upstream closes remaining P0 issues below. --- -## 2. Known Open Bugs (Affecting SmartSht Import/Eval) +## 2. Remaining Risks — Open Upstream Issues (Affecting SmartSht Import/Eval) These are open issues on GitHub that directly impact SmartSht's uploaded worksheet behavior: @@ -89,19 +90,14 @@ Based on the documented categories and what's commonly used in Excel but NOT lis ## 4. Date/Time Specific Issues -Dates are the most problematic area for SmartSht imports: +Dates remain a high-risk area for SmartSht imports, with partial relief in 0.9.3: 1. **Serial number interpretation** — Formualizer handles Excel serial dates (1900 system) but: - - Issue #312: Date arithmetic (+/-) preserves a Date type Excel doesn't have - - Issue #290: Many text-to-date conversions fail that Excel accepts - - Issue #291: Date text comparisons in COUNTIF/SUMIF diverge + - Issue #312: Date arithmetic (+/-) preserves a Date type Excel doesn't have (**still open**) + - Issue #290: Partially addressed in 0.9.3 (#398) for single-digit years, `24:00`, truncated fractional seconds, month-year forms; remaining cases tracked in #416 + - Issue #291: Date text comparisons in COUNTIF/SUMIF diverge (**still open**) -2. **Missing date text formats** that Excel parses but Formualizer rejects: - - Single-digit year: `1/2/5` (January 2, 2005) - - 24:00 as midnight - - Fractional seconds: `12:30:45.5` - - Month-year only: `Jan 2024` - - DATEVALUE/TIMEVALUE with non-standard inputs +2. **Date text forms** — 0.9.3 accepts more Excel-compatible text; still verify DATEVALUE/TIMEVALUE routing and `"24:00"`-as-full-day arithmetic (#416). 3. **Time functions with dates** — When a cell contains a datetime serial like `45488.75`, functions like HOUR/MINUTE/SECOND should extract the fractional part, but the Date type preservation bug (#312) may cause incorrect extraction. @@ -141,14 +137,14 @@ All functions using criteria matching (wildcards, comparisons) are affected by # ### P0 — Blocking SmartSht core functionality 1. **Date arithmetic type preservation** (#312) — Causes formula results to be Date objects instead of numbers -2. **VLOOKUP/HLOOKUP approximate mode sortedness** (#283) — Returns garbage data -3. **Date text parsing gaps** (#290) — Common date formats rejected +2. **VLOOKUP/HLOOKUP approximate mode sortedness** (#283) — Returns garbage data +3. **Date text parsing residual gaps** (#290 / #416) — Partial fix in 0.9.3; remaining DATEVALUE/TIMEVALUE / full-day `24:00` cases 4. **Wildcard/criteria matching** (#295) — COUNTIF/SUMIF wrong results and perf issues ### P1 — Causes incorrect data after user edits 5. **MATCH blank→0 coercion** (#319) -6. **COUNTBLANK on sparse ranges** (#285) +6. **COUNTBLANK on sparse ranges** (#285) — Partial arithmetic blank counting in 0.9.3; not all semantics 7. **Date text in COUNTIF/SUMIF criteria** (#291) 8. **Row insert invalidation for MATCH/INDEX** (#313) @@ -180,13 +176,17 @@ This means imported worksheets display correctly, but editing an imported formul ## 9. Upgrade Path +Current pin: + ```bash -npm install @ocean8219/formualizer@^0.8.4 +# already in package.json +"@ocean8219/formualizer": "^0.9.3" ``` -After upgrading, re-test: -- Date arithmetic formulas -- VLOOKUP with approximate matching -- COUNTIF/SUMIF with wildcard criteria -- Named range references across sheets -- Row/column insert operations with dependent formulas +Before any further upgrade: + +1. Run `npm run test:realengine` (golden set in `formualizer.realengine.test.ts`) +2. Re-check import honesty warnings on a styled workbook with formulas +3. Spot-check: date arithmetic (#312), approx VLOOKUP (#283), COUNTIF wildcards (#295) + +Do **not** upgrade past 0.9.3 solely because a newer tag exists — require golden-set green + known-risk review. diff --git a/docs/planning/README.md b/docs/planning/README.md index adf026e..ae7ab29 100644 --- a/docs/planning/README.md +++ b/docs/planning/README.md @@ -2,6 +2,9 @@ These documents describe the technical architecture of smartsh!t's internal systems. +> **Living forward plan:** [`docs/strategy/2026-09-24-usefulness-first-strategy.md`](../strategy/2026-09-24-usefulness-first-strategy.md) +> Useful first, unique second. P0 complete; formatting sandbox is P1. + ## Core Focus smartsh!t is a **spreadsheet understanding tool**. The AI and tooling exist to help users diff --git a/docs/strategy/2026-09-24-usefulness-first-strategy.md b/docs/strategy/2026-09-24-usefulness-first-strategy.md new file mode 100644 index 0000000..eb91ac3 --- /dev/null +++ b/docs/strategy/2026-09-24-usefulness-first-strategy.md @@ -0,0 +1,133 @@ +# Strategy: Useful First, Unique Second + +**Status:** Living — current forward plan +**Date:** 2026-09-24 (updated 2026-09-25) +**Principle:** Ship a spreadsheet people can trust and finish work in. Differentiation (auditor, privacy, formatting polish) comes after the core job works. + +> **Trust code over reviews.** Several docs in `docs/` are historical snapshots. Prefer this file + `package.json` + current `src/` over stale review claims. + +--- + +## 1. North star + +**Core job (must work):** + +``` +Import → Trust numbers → Understand structure → Ask grounded questions → Safe edit → Format polish +``` + +**Not the job (v1):** + +- Unbounded autonomous agent (plan → tool → observe → replan forever) +- Replace Excel / Google Sheets for teams +- Uniqueness theater (stub NLP, unused ONNX upload, collab before usefulness) + +**Agent posture:** Bounded tools with preview/undo. Formatting and tedious styling are in-scope. Open-ended workbook rewriting is not. + +--- + +## 2. P0 Backlog — Useful First + +| ID | Item | Status | Notes | +|----|------|--------|-------| +| **P0.1** | Formula engine parity | **DONE** | On `@ocean8219/formualizer@^0.9.3`. Gaps doc updated; golden-set realengine tests extended. | +| **P0.2** | Import honesty | **DONE** | Warn when formulas use Excel cached values / styles appear dropped. Surfaced via import meta → chat/toast. | +| **P0.3** | Act-path safety | **DONE** | `apply_formula` gap detection + Apply/Reject preview; `confirmGaps` override. | +| **P0.4** | Activation UI | **DONE** | Overlay waits for audit/grace; never auto-dismisses on critical/high; lists finding titles. | +| **P0.5** | AI quality loop (foundation) | **DONE** | Thumbs + optional detail on thumbs-down for failover analysis. | + +### Implementation paths + +| ID | Key files | +|----|-----------| +| P0.1 | `docs/formualizer-gaps.md`, `src/engine/formualizer.realengine.test.ts` | +| P0.2 | `src/io/xlsx.ts`, `src/store/importOrchestration.ts`, `src/components/Toolbar.tsx` | +| P0.3 | `src/agent/toolHandlers/columnOps.ts`, `src/lib/previewBuilders.ts`, `src/store/slices/chatSlice.ts` | +| P0.4 | `src/components/ImportInsightsOverlay.tsx` | +| P0.5 | `src/ai/chatFeedback.ts`, `src/components/ChatPanel.tsx` | + +--- + +## 3. Priority order going forward + +### P0 — Useful — **COMPLETE (2026-09-25)** + +Gate before uniqueness spend (smoke-test after sync): + +1. Import a representative budget (.xlsx) +2. Key totals match Excel (or user is warned) +3. Insights + critical audit findings visible without chat +4. ≥5 grounded Q&A turns without nonsense +5. Safe edits with preview / undo +6. No marketing claims for stub surfaces + +### P1 — Competitive polish (including formatting sandbox) — **NEXT** + +| ID | Work | Outcome | +|----|------|---------| +| P1.1 | Extend `format_cells` | Expose `CellFormat`: underline, strikethrough, fontFamily, align, wrap, borders | +| P1.2 | Layout tools | Agent + sandbox: column width / row height | +| P1.3 | Parser phrases | NL coverage for borders, auto-fit, row height, fonts | +| P1.4 | Style recipes | Bounded macros: `header`, `total_row`, `table_polish` (preview once) | +| P1.5 | Free-tier alignment | Gate auto-fix depth, not first understanding | + +**In scope:** cell styles, borders, fonts, sizes, alignment, wrap, row/col sizing, preset recipes. +**Out of scope:** Excel group/outline hierarchy (unless UI supports it), unbounded “make it pretty”, open autonomy loops. + +### P2 — Unique (last) + +| ID | Work | +|----|------| +| P2.1 | Auditor as brand / free viral audit funnel | +| P2.2 | Honest privacy/BYOK narrative (no stub NLP claims) | +| P2.3 | Kill or ship façades (NLP MiniLM / ONNX upload) | + +--- + +## 4. Formatting sandbox strategy (P1 detail) + +``` +NL → Pipeline (regex / macro / LLM) + → format_cells | layout tools | style recipes + → Apply/Reject (LLM) or single undo (safe regex) + → Store mutations (setCellFormat / setColumnWidth / setRowHeight) +``` + +Same executor spine. No new agent runtime. + +1. Wire `buildFormatPatch` + tool schema to full `CellFormat` +2. Add layout tools + ExecutionContext hooks +3. Sandbox API parity +4. Parser + false-positive corpus tests +5. Preset recipes via macro/template — preview bulk format + +--- + +## 5. Doc hierarchy + +| Layer | Role | Canonical files | +|-------|------|-----------------| +| **Living strategy** | What we build next | **This file** | +| **Product truth** | Public claims | `README.md`, landing — must match code | +| **Ops** | Deploy / env | `DEPLOY.md`, `ENV.md` | +| **Change log** | What landed | `MAJOR_CHANGES.md` | +| **Historical** | Context only | `docs/ARCHIVE.md` | +| **Planning specs** | Architecture intent | `docs/planning/*` — verify against `src/` | + +--- + +## 6. Explicit non-goals + +- Cursor-like unbounded agent loop +- More LLM backends before trust is green +- Collaboration / marketplace before core trust +- Expanding uniqueness narrative while core job fails + +--- + +## Related + +- Planning priorities: [`docs/planning/README.md`](../planning/README.md) +- Formatter sketch: [`docs/planning/11-tools-formatter.md`](../planning/11-tools-formatter.md) +- Engine gaps: [`docs/formualizer-gaps.md`](../formualizer-gaps.md) +- Archived reviews: [`docs/ARCHIVE.md`](../ARCHIVE.md) diff --git a/src/agent/toolHandlers/columnOps.test.ts b/src/agent/toolHandlers/columnOps.test.ts new file mode 100644 index 0000000..7b48b09 --- /dev/null +++ b/src/agent/toolHandlers/columnOps.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect, vi } from 'vitest' +import { createEmptySheet, refToCell } from '@/engine/spreadsheet' +import { detectApplyFormulaRangeGapRisk, handleApplyFormula } from './columnOps' +import type { ExecutionContext } from '../executor' + +function makeCtx(sheet: ReturnType): ExecutionContext { + return { + getActiveSheet: () => sheet, + getSheets: () => [sheet], + getComputedValue: (row, col) => { + const cell = sheet.cells[refToCell(row, col)] + if (cell?.value == null) return '' + return String(cell.value) + }, + setCellValue: (cellId, value, formula) => { + sheet.cells[cellId] = { + value: formula ? null : value, + formula, + } + }, + setCellFormat: () => {}, + bulkSetCells: () => {}, + applySortPatch: () => {}, + setFilters: () => {}, + deleteRow: () => {}, + insertRow: () => {}, + addSheet: () => {}, + renameSheet: () => {}, + pushHistory: vi.fn(), + } +} + +describe('detectApplyFormulaRangeGapRisk', () => { + it('flags SUM ranges that exclude an adjacent numeric cell', () => { + const sheet = createEmptySheet('S') + sheet.cells = { + A1: { value: 10 }, + A2: { value: 20 }, + A3: { value: 30 }, + A4: { value: 40 }, + } + const risk = detectApplyFormulaRangeGapRisk( + '=SUM(A1:A3)', + sheet, + (row, col) => String(sheet.cells[refToCell(row, col)]?.value ?? ''), + 'A5', + ) + expect(risk).toContain('A4') + }) + + it('returns null when the range covers adjacent data', () => { + const sheet = createEmptySheet('S') + sheet.cells = { + A1: { value: 10 }, + A2: { value: 20 }, + A3: { value: 30 }, + } + const risk = detectApplyFormulaRangeGapRisk( + '=SUM(A1:A3)', + sheet, + () => '', + 'A4', + ) + expect(risk).toBeNull() + }) +}) + +describe('handleApplyFormula range-gap safety', () => { + it('blocks gap-risk formulas unless confirmGaps is set', () => { + const sheet = createEmptySheet('S') + sheet.cells = { + A1: { value: 10 }, + A2: { value: 20 }, + A3: { value: 30 }, + A4: { value: 40 }, + } + const ctx = makeCtx(sheet) + const blocked = handleApplyFormula( + { cell: 'A5', formula: '=SUM(A1:A3)' }, + ctx, + sheet, + ) + expect(blocked.success).toBe(false) + expect(blocked.message).toMatch(/range gap risk/i) + + const forced = handleApplyFormula( + { cell: 'A5', formula: '=SUM(A1:A3)', confirmGaps: true }, + ctx, + sheet, + ) + expect(forced.success).toBe(true) + expect(sheet.cells.A5?.formula).toBe('=SUM(A1:A3)') + }) +}) diff --git a/src/agent/toolHandlers/columnOps.ts b/src/agent/toolHandlers/columnOps.ts index 85c1c3a..aefb049 100644 --- a/src/agent/toolHandlers/columnOps.ts +++ b/src/agent/toolHandlers/columnOps.ts @@ -5,9 +5,12 @@ import type { SheetData } from '@/types' import { refToCell, cellToRef, letterToCol } from '@/engine/spreadsheet' import { findHeaderRow } from '@/lib/sheetSort' import { getColumnDataRows } from '@/lib/sheetRows' +import { extractRangeRefs } from '@/auditor/utils' import type { ToolHandler, BulkUpdates } from './types' import { applyBulk, requireColumn } from './types' -import type { ExecutionContext } from '../executor' +import type { ExecutionContext, ExecutionResult } from '../executor' + +const AGGREGATE_PATTERN = /\b(SUM|AVERAGE|COUNT|COUNTA|MIN|MAX|SUBTOTAL)\b/i export const handleRenameHeader: ToolHandler = (params, ctx, sheet) => { const col = requireColumn(params.column, sheet, ctx, 'rename_header') @@ -76,24 +79,108 @@ export const handleApplyFormula: ToolHandler = (params, ctx, sheet) => { // Bare column letter — place formula below last populated cell if (/^[A-Z]{1,3}$/.test(target)) { - return applyFormulaToColumn(target, formula, sheet, ctx) + return applyFormulaToColumn(target, formula, sheet, ctx, params) } // Explicit cell reference if (/^[A-Z]{1,3}\d+$/.test(target)) { - return applyFormulaToCell(target, formula, ctx) + return applyFormulaToCell(target, formula, sheet, ctx, params) } return { success: false, message: `"${target}" is not a valid cell or column reference`, modified: 0 } } +/** + * Detect classic auditor "range gap" risk: an aggregate range that excludes an + * immediately adjacent numeric cell. Returns a human-readable warning or null. + */ +export function detectApplyFormulaRangeGapRisk( + formula: string, + sheet: SheetData, + getComputedValue: (row: number, col: number) => string, + formulaCellId?: string, +): string | null { + if (!AGGREGATE_PATTERN.test(formula)) return null + const ranges = extractRangeRefs(formula.replace(/^=/, '')) + const formulaRef = formulaCellId ? cellToRef(formulaCellId) : null + + for (const { range, start, end } of ranges) { + const startRef = cellToRef(start) + const endRef = cellToRef(end) + if (startRef.col === endRef.col) { + const col = startRef.col + const minRow = Math.min(startRef.row, endRef.row) + const maxRow = Math.max(startRef.row, endRef.row) + if (minRow > 0) { + const aboveId = refToCell(minRow - 1, col) + if (!formulaRef || aboveId !== formulaCellId) { + const num = parseNumericCell(sheet, aboveId, minRow - 1, col, getComputedValue) + if (num != null) { + return `Range ${range} excludes adjacent ${aboveId} (${num}). Extend the range or pass confirmGaps=true to apply anyway.` + } + } + } + const belowId = refToCell(maxRow + 1, col) + if (!formulaRef || belowId !== formulaCellId) { + const num = parseNumericCell(sheet, belowId, maxRow + 1, col, getComputedValue) + if (num != null) { + return `Range ${range} excludes adjacent ${belowId} (${num}). Extend the range or pass confirmGaps=true to apply anyway.` + } + } + } + if (startRef.row === endRef.row) { + const row = startRef.row + const minCol = Math.min(startRef.col, endRef.col) + const maxCol = Math.max(startRef.col, endRef.col) + if (minCol > 0) { + const leftId = refToCell(row, minCol - 1) + if (!formulaRef || leftId !== formulaCellId) { + const num = parseNumericCell(sheet, leftId, row, minCol - 1, getComputedValue) + if (num != null) { + return `Range ${range} excludes adjacent ${leftId} (${num}). Extend the range or pass confirmGaps=true to apply anyway.` + } + } + } + const rightId = refToCell(row, maxCol + 1) + if (!formulaRef || rightId !== formulaCellId) { + const num = parseNumericCell(sheet, rightId, row, maxCol + 1, getComputedValue) + if (num != null) { + return `Range ${range} excludes adjacent ${rightId} (${num}). Extend the range or pass confirmGaps=true to apply anyway.` + } + } + } + } + return null +} + +function parseNumericCell( + sheet: SheetData, + cellId: string, + row: number, + col: number, + getComputedValue: (row: number, col: number) => string, +): number | null { + const cell = sheet.cells[cellId] + if (!cell && !getComputedValue(row, col)) return null + const raw = cell?.value + if (typeof raw === 'number' && Number.isFinite(raw)) return raw + const computed = getComputedValue(row, col).replace(/[$,]/g, '') + const num = parseFloat(computed) + return Number.isFinite(num) && computed.trim() !== '' ? num : null +} + +function confirmGapsRequested(params: Record): boolean { + return params.confirmGaps === true || params.confirmGaps === 'true' || params.force === true +} + /** Apply a formula below the last data row in a column. */ function applyFormulaToColumn( colLetter: string, formula: string, sheet: SheetData, ctx: ExecutionContext, -) { + params: Record, +): ExecutionResult { const colIdx = letterToCol(colLetter) // Use unified row bounds that exclude summary rows @@ -108,14 +195,40 @@ function applyFormulaToColumn( ? formula : `${formula}(${colLetter}${bounds.firstRow + 1}:${colLetter}${bounds.lastRow + 1})` + const gapRisk = detectApplyFormulaRangeGapRisk(fullFormula, sheet, ctx.getComputedValue, cellId) + if (gapRisk && !confirmGapsRequested(params)) { + return { + success: false, + message: `Blocked apply_formula (range gap risk): ${gapRisk}`, + modified: 0, + } + } + ctx.pushHistory('Apply formula') ctx.setCellValue(cellId, null, fullFormula) - return { success: true, message: `Added ${formula} formula in ${cellId}`, modified: 1 } + const warn = gapRisk ? ` Warning: ${gapRisk}` : '' + return { success: true, message: `Added ${fullFormula} formula in ${cellId}.${warn}`, modified: 1 } } /** Apply a formula directly to a named cell. */ -function applyFormulaToCell(cellRef: string, formula: string, ctx: ExecutionContext) { +function applyFormulaToCell( + cellRef: string, + formula: string, + sheet: SheetData, + ctx: ExecutionContext, + params: Record, +): ExecutionResult { + const gapRisk = detectApplyFormulaRangeGapRisk(formula, sheet, ctx.getComputedValue, cellRef) + if (gapRisk && !confirmGapsRequested(params)) { + return { + success: false, + message: `Blocked apply_formula (range gap risk): ${gapRisk}`, + modified: 0, + } + } + ctx.pushHistory('Apply formula') ctx.setCellValue(cellRef, null, formula) - return { success: true, message: `Set formula in ${cellRef}`, modified: 1 } + const warn = gapRisk ? ` Warning: ${gapRisk}` : '' + return { success: true, message: `Set formula in ${cellRef}.${warn}`, modified: 1 } } diff --git a/src/ai/chatFeedback.ts b/src/ai/chatFeedback.ts index b0109ee..71ba386 100644 --- a/src/ai/chatFeedback.ts +++ b/src/ai/chatFeedback.ts @@ -6,6 +6,8 @@ export interface ChatFeedbackEntry { messageId: string rating: ChatFeedbackRating timestamp: string + /** Optional short context for quality/failover analysis (not sent remotely). */ + detail?: string } // amazonq-ignore-next-line @@ -27,13 +29,26 @@ export function getFeedbackForMessage(messageId: string): ChatFeedbackRating | n return loadChatFeedback().find((e) => e.messageId === messageId)?.rating ?? null } -export function recordChatFeedback(messageId: string, rating: ChatFeedbackRating): void { +export function recordChatFeedback( + messageId: string, + rating: ChatFeedbackRating, + detail?: string, +): void { + const trimmedDetail = detail?.trim().slice(0, 200) || undefined const entries = loadChatFeedback().filter((e) => e.messageId !== messageId) - entries.push({ messageId, rating, timestamp: new Date().toISOString() }) + entries.push({ + messageId, + rating, + timestamp: new Date().toISOString(), + detail: trimmedDetail, + }) try { localStorage.setItem(STORAGE_NS, JSON.stringify(entries.slice(-200))) } catch { // ignore quota errors } - recordTelemetry(rating === 'up' ? 'feedbackUp' : 'feedbackDown', messageId) + const telemetryDetail = trimmedDetail + ? `${messageId}|${trimmedDetail}` + : messageId + recordTelemetry(rating === 'up' ? 'feedbackUp' : 'feedbackDown', telemetryDetail) } diff --git a/src/components/ChatPanel.tsx b/src/components/ChatPanel.tsx index 990c6ea..0afc385 100644 --- a/src/components/ChatPanel.tsx +++ b/src/components/ChatPanel.tsx @@ -171,7 +171,11 @@ export function ChatPanel({ isMobileOpen, onCloseMobile, embedded }: ChatPanelPr } const handleFeedback = (messageId: string, rating: ChatFeedbackRating) => { - recordChatFeedback(messageId, rating) + const msg = messages.find((m) => m.id === messageId) + const detail = msg + ? `${msg.toolUsed ?? 'assistant'}:${String(msg.content ?? '').slice(0, 80)}` + : undefined + recordChatFeedback(messageId, rating, detail) setFeedbackById((prev) => ({ ...prev, [messageId]: rating })) } diff --git a/src/components/ImportInsightsOverlay.tsx b/src/components/ImportInsightsOverlay.tsx index a801e54..ee30e81 100644 --- a/src/components/ImportInsightsOverlay.tsx +++ b/src/components/ImportInsightsOverlay.tsx @@ -3,11 +3,12 @@ * immediately after a file import. Surfaces key totals, structure, and * audit findings without requiring user initiation. * - * Appears as a dismissible toast-like panel anchored to the bottom-right, - * auto-dismisses after 30 seconds or on user interaction. + * Appears as a dismissible toast-like panel anchored to the bottom-right. + * Auto-dismisses after 30 seconds unless critical/high audit findings are + * present — those stay until the user dismisses or opens the auditor. */ -import { useState, useEffect, useMemo, useCallback } from 'react' +import { useState, useEffect, useMemo, useCallback, useRef } from 'react' import { useStore } from '@/store/useStore' import { useShallow } from 'zustand/react/shallow' import { computeSheetInsights } from '@/ai/sheetInsights' @@ -24,6 +25,8 @@ import { } from 'lucide-react' const AUTO_DISMISS_MS = 30_000 +/** Wait for post-import audit (~500ms) before arming auto-dismiss. */ +const AUDIT_GRACE_MS = 1_000 function formatCurrency(n: number): string { return new Intl.NumberFormat('en-US', { @@ -48,14 +51,33 @@ export function ImportInsightsOverlay() { const [visible, setVisible] = useState(false) const [dismissed, setDismissed] = useState(false) + /** Import bump so grace/audit wait resets per import (not on stale audit). */ + const [importSeq, setImportSeq] = useState(0) + /** True once audit landed for this import, or grace elapsed without critical findings. */ + const [canAutoDismiss, setCanAutoDismiss] = useState(false) + const waitingForAuditRef = useRef(false) const sheet = getActiveSheet() const cellCount = Object.keys(sheet.cells).length + const hasCriticalIssues = lastAuditResult?.findings?.some( + (f) => f.severity === 'critical' || f.severity === 'high', + ) ?? false + + const criticalFindings = useMemo(() => { + const findings = lastAuditResult?.findings ?? [] + return findings + .filter((f) => f.severity === 'critical' || f.severity === 'high') + .slice(0, 3) + }, [lastAuditResult]) + // Detect when a new import happens via custom event from importOrchestration useEffect(() => { const handler = () => { if (dismissed) return + waitingForAuditRef.current = true + setCanAutoDismiss(false) + setImportSeq((n) => n + 1) setVisible(true) setDismissed(false) } @@ -63,12 +85,42 @@ export function ImportInsightsOverlay() { return () => document.removeEventListener('smartsht:import-complete', handler) }, [dismissed]) - // Auto-dismiss timer + // Arm auto-dismiss only after audit for this import, or after grace if audit never arrives. + // Do not trust lastAuditResult that existed before import-complete (stale prior file). + useEffect(() => { + if (!visible || importSeq === 0) return + waitingForAuditRef.current = true + setCanAutoDismiss(false) + const grace = setTimeout(() => { + if (!waitingForAuditRef.current) return + waitingForAuditRef.current = false + setCanAutoDismiss(true) + }, AUDIT_GRACE_MS) + return () => clearTimeout(grace) + }, [visible, importSeq]) + + // When audit result updates while waiting, settle early (audit path ~500ms). + // Only react to lastAuditResult changes — do not settle on the stale pre-import result. + useEffect(() => { + if (!waitingForAuditRef.current) return + waitingForAuditRef.current = false + const critical = lastAuditResult?.findings?.some( + (f) => f.severity === 'critical' || f.severity === 'high', + ) ?? false + setCanAutoDismiss(!critical) + }, [lastAuditResult]) + + // Never auto-dismiss while critical/high findings are present useEffect(() => { - if (!visible) return + if (hasCriticalIssues) setCanAutoDismiss(false) + }, [hasCriticalIssues]) + + // Auto-dismiss timer — only after audit/grace, and never with critical findings + useEffect(() => { + if (!visible || !canAutoDismiss || hasCriticalIssues) return const timer = setTimeout(() => setVisible(false), AUTO_DISMISS_MS) return () => clearTimeout(timer) - }, [visible]) + }, [visible, canAutoDismiss, hasCriticalIssues]) const insights = useMemo(() => { if (!visible || cellCount < 5) return null @@ -98,9 +150,6 @@ export function ImportInsightsOverlay() { if (!visible || !insights || !profile) return null const auditIssueCount = lastAuditResult?.findings?.length ?? 0 - const hasCriticalIssues = lastAuditResult?.findings?.some( - (f) => f.severity === 'critical' || f.severity === 'high', - ) ?? false const hasFinancialData = (insights.totalIncome ?? 0) > 0 || (insights.totalExpenses ?? 0) > 0 const hasOutliers = (insights.outliers?.length ?? 0) > 0 @@ -119,7 +168,7 @@ export function ImportInsightsOverlay() { className="rounded-2xl border overflow-hidden" style={{ background: 'var(--surface-panel)', - borderColor: 'var(--neutral-200)', + borderColor: hasCriticalIssues ? 'var(--danger, #dc2626)' : 'var(--neutral-200)', boxShadow: '0 12px 40px oklch(0.1 0 0 / 0.12), 0 4px 12px oklch(0.1 0 0 / 0.06)', }} > @@ -194,24 +243,36 @@ export function ImportInsightsOverlay() { )} - {/* Audit findings callout */} + {/* Audit findings callout — surface critical titles without requiring chat */} {auditIssueCount > 0 && ( )} @@ -240,7 +301,11 @@ export function ImportInsightsOverlay() { - Auto-dismisses in 30s + {hasCriticalIssues + ? 'Stays open until dismissed' + : canAutoDismiss + ? 'Auto-dismisses in 30s' + : 'Checking for issues…'} diff --git a/src/components/Toolbar.tsx b/src/components/Toolbar.tsx index 91e7f60..af02623 100644 --- a/src/components/Toolbar.tsx +++ b/src/components/Toolbar.tsx @@ -98,15 +98,10 @@ export function Toolbar() { if (file.name.match(/\.(xlsx?|csv)$/i)) { pushHistory('Import file'); const { workbook, meta } = await importWorkbookFromFileWithMeta(file); - useStore.getState().importWorkbook(workbook, { fileName: file.name }); + useStore.getState().importWorkbook(workbook, { fileName: file.name, warnings: meta.warnings }); if (meta.warnings.length) { recordTelemetry('importTruncationEvents', `Toolbar import: ${file.name}`); - useStore.getState().addMessage({ - id: uuid(), - role: 'assistant', - content: `Import note: ${meta.warnings.join(' ')}`, - timestamp: Date.now(), - }); + // Warnings are also appended to the import chat message via importOrchestration. } // Post-import nudge: suggest the auditor on first import const hasSeenAuditorNudge = localStorage.getItem('smartsht-auditor-nudge-seen'); diff --git a/src/engine/formualizer.realengine.test.ts b/src/engine/formualizer.realengine.test.ts index 1207c03..cb86fa2 100644 --- a/src/engine/formualizer.realengine.test.ts +++ b/src/engine/formualizer.realengine.test.ts @@ -111,3 +111,77 @@ describe('real formualizer WASM — error propagation', () => { expect(String(wb.evaluateCell('Sheet1', 1, 1))).toBe('#NAME?') }) }) + +/** + * Small Excel-parity golden set — keep cases cheap and high-signal. + * Expand from real customer workbooks; do not grow unbounded. + */ +describe('real formualizer WASM — excel parity golden set', () => { + const cases: Array<{ + name: string + setup: (wb: ReturnType) => void + expect: (wb: ReturnType) => void + }> = [ + { + name: 'SUM + IF + arithmetic', + setup: (wb) => { + wb.setValue('Sheet1', 1, 1, 10) + wb.setValue('Sheet1', 2, 1, 20) + wb.setValue('Sheet1', 3, 1, 30) + wb.setFormula('Sheet1', 4, 1, '=SUM(A1:A3)') + wb.setFormula('Sheet1', 5, 1, '=IF(A4>50, A4*2, A4)') + }, + expect: (wb) => { + expect(Number(wb.evaluateCell('Sheet1', 4, 1))).toBe(60) + expect(Number(wb.evaluateCell('Sheet1', 5, 1))).toBe(120) + }, + }, + { + name: 'AVERAGE and COUNT', + setup: (wb) => { + wb.setValue('Sheet1', 1, 1, 2) + wb.setValue('Sheet1', 2, 1, 4) + wb.setValue('Sheet1', 3, 1, 6) + wb.setFormula('Sheet1', 4, 1, '=AVERAGE(A1:A3)') + wb.setFormula('Sheet1', 5, 1, '=COUNT(A1:A3)') + }, + expect: (wb) => { + expect(Number(wb.evaluateCell('Sheet1', 4, 1))).toBe(4) + expect(Number(wb.evaluateCell('Sheet1', 5, 1))).toBe(3) + }, + }, + { + name: 'VLOOKUP exact match', + setup: (wb) => { + wb.setValue('Sheet1', 1, 1, 'Rent') + wb.setValue('Sheet1', 1, 2, 1200) + wb.setValue('Sheet1', 2, 1, 'Food') + wb.setValue('Sheet1', 2, 2, 400) + wb.setFormula('Sheet1', 3, 1, '=VLOOKUP("Food",A1:B2,2,FALSE)') + }, + expect: (wb) => { + expect(Number(wb.evaluateCell('Sheet1', 3, 1))).toBe(400) + }, + }, + { + name: 'IFERROR wraps #DIV/0!', + setup: (wb) => { + wb.setValue('Sheet1', 1, 1, 10) + wb.setValue('Sheet1', 2, 1, 0) + wb.setFormula('Sheet1', 3, 1, '=IFERROR(A1/A2, 0)') + }, + expect: (wb) => { + expect(Number(wb.evaluateCell('Sheet1', 3, 1))).toBe(0) + }, + }, + ] + + for (const tc of cases) { + it(tc.name, () => { + const wb = newSheet() + tc.setup(wb) + wb.evaluateAll() + tc.expect(wb) + }) + } +}) diff --git a/src/io/xlsx.test.ts b/src/io/xlsx.test.ts index 3e447df..dad48f1 100644 --- a/src/io/xlsx.test.ts +++ b/src/io/xlsx.test.ts @@ -75,6 +75,37 @@ describe('workbook import', () => { expect(Array.isArray(meta.warnings)).toBe(true) }) + it('warns when formulas are imported with Excel cached values (not live-eval)', async () => { + const book = XLSX.utils.book_new() + const ws = XLSX.utils.aoa_to_sheet([ + ['Item', 'Amount'], + ['Rent', 100], + ['Food', 50], + ['Total', 150], + ]) + // Attach a formula + cached Excel value the way SheetJS stores them + ws['B4'] = { t: 'n', v: 150, f: 'SUM(B2:B3)' } + XLSX.utils.book_append_sheet(book, ws, 'Sheet1') + const buffer = XLSX.write(book, { type: 'array', bookType: 'xlsx' }) as ArrayBuffer + + const { workbook, meta } = await importWorkbookFromFileWithMeta(fakeFile('formulas.xlsx', buffer)) + expect(workbook.sheets[0].cells.B4?.formula).toMatch(/SUM/i) + expect(meta.warnings.some((w) => /live-recalculate|cached|saved values/i.test(w))).toBe(true) + }) + + it('does not warn about styles on a plain unstyled workbook', async () => { + // Plain SheetJS write yields no rawCell.s — that is not "dropped styles". + const buffer = xlsxBuffer([ + ['A', 'B', 'C'], + [1, 2, 3], + [4, 5, 6], + [7, 8, 9], + [10, 11, 12], + ]) + const { meta } = await importWorkbookFromFileWithMeta(fakeFile('plain.xlsx', buffer)) + expect(meta.warnings.some((w) => /styles/i.test(w))).toBe(false) + }) + it('preserves merged cell regions as canonical range refs', async () => { const book = XLSX.utils.book_new() const ws = XLSX.utils.aoa_to_sheet([['Header', 'B', 'C'], [1, 2, 3]]) diff --git a/src/io/xlsx.ts b/src/io/xlsx.ts index 127fd28..7b8d0c5 100644 --- a/src/io/xlsx.ts +++ b/src/io/xlsx.ts @@ -202,6 +202,20 @@ function mapBorderStyle(border: { style?: string; color?: { rgb?: string; argb?: return `${weight} solid ${color}` } +/** + * SheetJS often attaches empty stubs like `{ patternType: "none" }` even on + * unstyled cells. Those are not evidence the workbook had styles to recover. + */ +function isMeaningfulStyleObject(style: Record): boolean { + if (!style || typeof style !== 'object') return false + if (style.fgColor || style.bgColor || style.fill || style.font || style.border || style.alignment) + return true + if (style.numFmt && typeof style.numFmt === 'object') return true + // patternType alone (e.g. "none") is a default stub, not real styling + const keys = Object.keys(style).filter((k) => k !== 'patternType') + return keys.length > 0 +} + /** * Strip prototype-polluting keys from a parsed workbook. * @@ -236,6 +250,14 @@ export async function importWorkbookFromFileWithMeta(file: File): Promise { const sheet = createEmptySheet(name.slice(0, 31)) @@ -266,6 +288,11 @@ export async function importWorkbookFromFileWithMeta(file: File): Promise = {} @@ -366,6 +394,9 @@ export async function importWorkbookFromFileWithMeta(file: File): Promise 0) { + const visualKeys = ['bgColor', 'fontColor', 'bold', 'italic', 'strikethrough', 'borders', 'fontFamily'] + if (visualKeys.some((k) => k in format)) visualStylesApplied += 1 + if ('numberFormat' in format) numberFormatsApplied += 1 if (!sheet.cells[cellId]) { sheet.cells[cellId] = { value: null, format: format as CellData['format'] } } else { @@ -466,6 +497,26 @@ export async function importWorkbookFromFileWithMeta(file: File): Promise 0) { + warnings.push( + `${formulaCellsWithCachedValue} formula${formulaCellsWithCachedValue === 1 ? '' : 's'} imported with Excel's saved values — they will not live-recalculate until you edit them.`, + ) + } + if (formulaCellsWithoutCachedValue > 0) { + warnings.push( + `${formulaCellsWithoutCachedValue} formula${formulaCellsWithoutCachedValue === 1 ? '' : 's'} had no Excel cached value; live evaluation may differ from Excel.`, + ) + } + // Only warn when style objects were present but nothing at all could be + // applied — do not warn for plain unstyled files (styleObjectsSeen === 0), + // nor for workbooks whose only styling is number formats, which are valid + // but intentionally non-visual. + if (styleObjectsSeen > 0 && visualStylesApplied === 0 && numberFormatsApplied === 0) { + warnings.push( + 'Style metadata was present but no visual styles (fill/font/borders) could be applied.', + ) + } + if (!sheets.length) { return { workbook: createEmptyWorkbook(baseName), diff --git a/src/lib/previewBuilders.test.ts b/src/lib/previewBuilders.test.ts index af48eb1..4afea3b 100644 --- a/src/lib/previewBuilders.test.ts +++ b/src/lib/previewBuilders.test.ts @@ -53,4 +53,20 @@ describe('previewBuilders', () => { ) expect(preview?.changes.length).toBeGreaterThan(0) }) + + it('buildActionPreview wires apply_formula', () => { + const sheet = sheetWithColB() + const preview = buildActionPreview( + 'apply_formula', + { cell: 'B4', formula: '=SUM(B2:B3)' }, + sheet, + () => '', + ) + expect(preview?.changes).toEqual([ + expect.objectContaining({ + cell: 'B4', + newFormula: '=SUM(B2:B3)', + }), + ]) + }) }) diff --git a/src/lib/previewBuilders.ts b/src/lib/previewBuilders.ts index 3c1584e..417d5d7 100644 --- a/src/lib/previewBuilders.ts +++ b/src/lib/previewBuilders.ts @@ -2,6 +2,7 @@ import type { CellChange, SheetData } from '@/types' import { cellToRef, refToCell, letterToCol } from '@/engine/spreadsheet' import { findLastDataRow } from '@/lib/sheetSort' import { resolveDeleteRow } from '@/lib/deleteRowPreview' +import { getColumnDataRows } from '@/lib/sheetRows' /** * Build CellChange[] previews for proposed mutations (Phase 1 grid overlay). @@ -80,6 +81,45 @@ export function previewModifyColumn( return changes } +/** Preview a single apply_formula write (cell or column-target). */ +export function previewApplyFormula( + sheet: SheetData, + params: Record, + getComputedValue: (row: number, col: number) => string, +): CellChange[] { + const target = String((params.cell ?? params.column ?? '')).trim().toUpperCase() + if (!target) return [] + + let formula = String(params.formula ?? '=SUM') + if (!formula.startsWith('=')) formula = `=${formula}` + + let cellId: string + let fullFormula = formula + + if (/^[A-Z]{1,3}$/.test(target)) { + const colIdx = letterToCol(target) + const bounds = getColumnDataRows(sheet, colIdx, getComputedValue) + if (!bounds) return [] + cellId = refToCell(bounds.lastRow + 1, colIdx) + fullFormula = formula.includes('(') + ? formula + : `${formula}(${target}${bounds.firstRow + 1}:${target}${bounds.lastRow + 1})` + } else if (/^[A-Z]{1,3}\d+$/.test(target)) { + cellId = target + } else { + return [] + } + + const current = sheet.cells[cellId] + return [{ + cell: cellId, + oldValue: current?.value ?? null, + newValue: null, + oldFormula: current?.formula, + newFormula: fullFormula, + }] +} + /** Attach preview.changes onto an action when the tool supports it. */ export function buildActionPreview( tool: string, @@ -101,6 +141,10 @@ export function buildActionPreview( ) return changes.length ? { changes } : undefined } + if (tool === 'apply_formula') { + const changes = previewApplyFormula(sheet, params, getComputedValue) + return changes.length ? { changes } : undefined + } if (tool === 'delete_row') { const resolved = resolveDeleteRow(sheet, params, getComputedValue) return resolved?.changes.length ? { changes: resolved.changes } : undefined diff --git a/src/store/__tests__/importOrchestration.test.ts b/src/store/__tests__/importOrchestration.test.ts index 7882347..e477054 100644 --- a/src/store/__tests__/importOrchestration.test.ts +++ b/src/store/__tests__/importOrchestration.test.ts @@ -49,6 +49,26 @@ describe('importOrchestration', () => { expect(access.messages[0].content).toContain('budget.xlsx') }) + it('surfaces import honesty warnings in chat and toast', () => { + const wb = createEmptyWorkbook('Budget') + wb.sheets[0].cells = { A1: { value: 'x' } } + const access = makeAccess(wb) + applyWorkbookImportEffects( + (fn) => { fn(access) }, + () => access, + wb, + { + fileName: 'budget.xlsx', + warnings: ['2 formulas imported with Excel\'s saved values — they will not live-recalculate until you edit them.'], + }, + ) + expect(access.messages[0].content).toContain('Import honesty') + expect(access.messages[0].content).toContain('live-recalculate') + expect(access.showToast).toHaveBeenCalledWith( + expect.objectContaining({ type: 'warning' }), + ) + }) + it('stays on the sheet and toasts instead of opening insights', () => { vi.useFakeTimers() const wb = createEmptyWorkbook('Big') diff --git a/src/store/importOrchestration.ts b/src/store/importOrchestration.ts index 019064b..bb79a2a 100644 --- a/src/store/importOrchestration.ts +++ b/src/store/importOrchestration.ts @@ -46,7 +46,7 @@ export function applyWorkbookImportEffects( set: (fn: (s: ImportEffectAccess) => void) => void, get: () => ImportEffectAccess, workbook: WorkbookData, - meta?: { fileName?: string }, + meta?: { fileName?: string; warnings?: string[] }, ): void { const sheet = workbook.sheets.find((s) => s.id === workbook.activeSheetId) ?? workbook.sheets[0] const sheetLines = summarizeImportedSheets(workbook) @@ -56,9 +56,13 @@ export function applyWorkbookImportEffects( const sheetList = sheetLines .map((s) => `**${s.name}** (${s.rows} row${s.rows === 1 ? '' : 's'})`) .join(', ') + const honestyNotes = (meta?.warnings ?? []).filter(Boolean) + const honestyBlock = honestyNotes.length + ? `\n\n**Import honesty:** ${honestyNotes.join(' ')}` + : '' const importMessage = multi - ? `Imported **${fileLabel}** with **${workbook.sheets.length} sheets**: ${sheetList}.\n\nYou're on **${sheet?.name ?? 'Sheet1'}**. Use the sheet tabs at the bottom to switch — I analyze the active sheet.` - : `Imported **${fileLabel}** — ${activeRows} rows on **${sheet?.name ?? 'Sheet 1'}**. Ready to analyze.\n\nAsk me anything about this data — try *"Explain this spreadsheet"* or *"Where am I overspending?"*` + ? `Imported **${fileLabel}** with **${workbook.sheets.length} sheets**: ${sheetList}.\n\nYou're on **${sheet?.name ?? 'Sheet1'}**. Use the sheet tabs at the bottom to switch — I analyze the active sheet.${honestyBlock}` + : `Imported **${fileLabel}** — ${activeRows} rows on **${sheet?.name ?? 'Sheet 1'}**. Ready to analyze.\n\nAsk me anything about this data — try *"Explain this spreadsheet"* or *"Where am I overspending?"*${honestyBlock}` set((s) => { s.messages.push({ @@ -76,6 +80,14 @@ export function applyWorkbookImportEffects( }) }) + if (honestyNotes.length > 0) { + get().showToast({ + type: 'warning', + message: honestyNotes[0].length > 120 ? `${honestyNotes[0].slice(0, 117)}…` : honestyNotes[0], + duration: 8000, + }) + } + if (activeRows > 5) { get().showToast({ type: 'info', diff --git a/src/store/slices/chatSlice.ts b/src/store/slices/chatSlice.ts index 5973952..a902f79 100644 --- a/src/store/slices/chatSlice.ts +++ b/src/store/slices/chatSlice.ts @@ -58,7 +58,7 @@ export interface ChatStoreAccess extends ChatState { setActivePanel: (panel: 'chat' | 'insights' | 'auditor' | 'inspector' | null) => void showToast: (toast: Omit) => void pushHistory: (desc: string) => void - importWorkbook: (workbook: WorkbookData, meta?: { fileName?: string }) => void + importWorkbook: (workbook: WorkbookData, meta?: { fileName?: string; warnings?: string[] }) => void setCellValue: (cellId: string, value: string | number | boolean | null, formula?: string) => void setCellFormat: (cellId: string, format: Partial) => void bulkSetCells: (cells: Record) => void @@ -274,7 +274,10 @@ export function createChatActions( importAttachedFile: async () => { const preview = get().attachedFilePreview if (!preview) return - get().importWorkbook(preview.workbook, { fileName: preview.fileName }) + get().importWorkbook(preview.workbook, { + fileName: preview.fileName, + warnings: preview.importWarnings, + }) set((s) => { s.attachedFilePreview = null s.messages.push({ @@ -295,6 +298,7 @@ export function createChatActions( 'clean_sheet_data', 'delete_row', 'modify_column', + 'apply_formula', 'execute_script', ]) for (const msg of state.messages) { @@ -403,7 +407,14 @@ export function createChatActions( }) } - const execution = executeAction(action, get as never, set as never) + // An explicit Apply after a preview was shown is the user's confirmation. + // apply_formula blocks on range-gap risk unless confirmGaps is set, so an + // action the user has reviewed and approved must carry that override — + // otherwise the reviewed formula is silently rejected on Apply. + const confirmedAction = action.tool === 'apply_formula' && action.preview + ? { ...action, params: { ...action.params, confirmGaps: true } } + : action + const execution = executeAction(confirmedAction, get as never, set as never) if (execution instanceof Promise) { void execution .then(finishAction) diff --git a/src/store/slices/workbookSlice.ts b/src/store/slices/workbookSlice.ts index fcb8801..58fd884 100644 --- a/src/store/slices/workbookSlice.ts +++ b/src/store/slices/workbookSlice.ts @@ -294,7 +294,7 @@ export interface WorkbookActions { deleteRow: (row: number) => void deleteColumn: (col: number) => void bulkSetCells: (cells: Record) => void - importWorkbook: (workbook: WorkbookData, meta?: { fileName?: string }) => void + importWorkbook: (workbook: WorkbookData, meta?: { fileName?: string; warnings?: string[] }) => void loadWorkbookData: (workbook: WorkbookData, opts?: { pushUndo?: boolean }) => void getActiveSheet: () => SheetData getCellData: (cellId: string) => CellData | undefined diff --git a/src/store/storeTypes.ts b/src/store/storeTypes.ts index 77f0a8a..8b5bde3 100644 --- a/src/store/storeTypes.ts +++ b/src/store/storeTypes.ts @@ -174,7 +174,7 @@ export interface AppState extends UIState, UIActions, FileActions, ChatActions { // Bulk operations (for AI) bulkSetCells: (cells: Record) => void - importWorkbook: (workbook: WorkbookData, meta?: { fileName?: string }) => void + importWorkbook: (workbook: WorkbookData, meta?: { fileName?: string; warnings?: string[] }) => void loadWorkbookData: (workbook: WorkbookData, opts?: { pushUndo?: boolean }) => void // Get helpers diff --git a/src/store/useStore.ts b/src/store/useStore.ts index b976b3f..f127e11 100644 --- a/src/store/useStore.ts +++ b/src/store/useStore.ts @@ -151,7 +151,7 @@ export const useStore = create()( // Wrap data-only import with chat/insights/audit orchestration. // Imported content is a new local workbook, not the file's existing cloud // workbook, so clear the cloud binding to avoid overwriting it on autosave. - importWorkbook: (workbook: WorkbookData, meta?: { fileName?: string }) => { + importWorkbook: (workbook: WorkbookData, meta?: { fileName?: string; warnings?: string[] }) => { workbookActions.importWorkbook(workbook, meta) useStore.getState().setActiveFileCloudId(null) applyWorkbookImportEffects(