think
16px
820px

Batch Upload Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: A full-page batch-upload workbench (≤100 files) with per-file AI auto-filing, a master-detail editor, multi-select batch operations, and a create-all queue — nothing persisted until Create, per-row retry, zero rollback of successes.

Architecture: A new web/src/features/documents/batch/ feature folder: a pure state model (batchModel.ts), two client-side queues (AI pipeline concurrency 3, creation concurrency 2) as hooks, and four presentational components wired by BatchUploadPage. Everything calls the endpoints shipped with the Smart Upload Assistant — the only backend change is one constant.

Tech Stack: React + Carbon (@carbon/react), react-router (useNavigate/useLocation state for the File[] handoff), TanStack Query hooks already in web/src/api/*, Go (one-line change).

Spec: docs/superpowers/specs/2026-07-04-batch-upload-design.md

Global Constraints (every task)

  • NEVER go test (test DSN == live demo Postgres). Go verify: cd go && go build ./... && go vet ./.... Web verify: cd web && npx tsc --noEmit && npx vite build.
  • NO new npm dependencies (npm install is broken: npm11/node25 arborist crash).
  • Deploy ONLY from repo root: docker compose -f deploy/docker-compose.yml --env-file deploy/mekari.env up -d --build obscura web; after deploy assert /me enabled_modules == [ai, correspondence, esign, semantic, watermarking] (dev-login director@obscura.local, host port 38080).
  • Commit per task on main; do NOT push unless asked; NEVER git add -A (go/obscura-server is a tracked ELF).
  • AI failures NEVER block creation; cancel/abandon leaves zero rows; blocked rows never hold the batch hostage (Create all (N) = creatable subset).
  • i18n en/id parity (tsc-enforced); central locales web/src/i18n/locales/{en,id}.ts nested newDoc:{} group; Edit-tool smart-quote gotcha — verify tsc, rewrite whole file with Write if quotes get mangled.
  • Clean up all e2e artifacts (purge = DELETE /documents/{id} then DELETE /documents/{id}/purge).

File Map

File Role
go/internal/httpapi/analysis_cache.go analysisCacheMax 64 → 256
web/src/App.tsx:53-57 route /documents/batch-upload
web/src/features/documents/DocumentsPage.tsx:234-242 toolbar button; modal multi-drop handoff
web/src/features/documents/UploadDocumentModal.tsx dropzone multiple + onMultiple prop
web/src/features/documents/batch/batchModel.ts (new) row type, statuses, warnings, pure helpers
web/src/features/documents/batch/useBatchAi.ts (new) analyze+suggest queue (conc. 3)
web/src/features/documents/batch/useBatchCreate.ts (new) create queue (conc. 2)
web/src/features/documents/batch/BatchUploadPage.tsx (new) page shell, top bar, wiring, beforeunload
web/src/features/documents/batch/BatchFileList.tsx (new) left list + selection semantics
web/src/features/documents/batch/BatchDetailPanel.tsx (new) right editor (single + "N selected" modes)
web/src/features/documents/batch/BatchPreviewModal.tsx (new) popup preview (blob URL)
web/src/i18n/locales/en.ts + id.ts newDoc.batch* strings
web/src/styles/app.css .batchup__* styles

Shared verified interfaces (consume as-is, all shipped on origin/main):
analyzeUpload(file, title?, lang?, signal?) → Promise<UploadAnalysis{disabled?, noText?, analysisId, summary, classification, tags}> and useUploadAiSettings(enabled) → {data?: {analyzeEnabled, suggestMode: 'after_analysis'|'immediate'|'both'}} (@/api/ai); uploadVersion(docID, file, extra?: {analysisId?, summary?}) + ACCEPTED_UPLOAD_TYPES (@/api/files); useSuggestFolder() mutation {title?, text?, filename?, k?} → [{folderId, path, score}] (@/api/semantic); useCreateDocument() mutation {title, folderId, classification, docType, formatId, customVars} → id (@/api/documents); api.PUT('/api/v1/documents/{docID}/tags', {params:{path:{docID}}, body:{tags}}) (@/api/client); useIdFormats() (@/api/docid); useActiveClassifications() (@/api/classifications); FolderSuggestBox {query, enabled, note?, currentFolderId, currentFolderPath, value, onChange} + MoveModal {open, itemName, onClose, onSubmit(targetFolderId)} (web/src/features/documents/); useMe + moduleEnabled (@/api/me, @/lib/nav).


Task 1: Cache bump + route + entry points

Files:
- Modify: go/internal/httpapi/analysis_cache.go (const analysisCacheMax = 64)
- Modify: web/src/App.tsx (routes, ~line 55)
- Modify: web/src/features/documents/DocumentsPage.tsx:234-242 (toolbar) + its UploadDocumentModal usage
- Modify: web/src/features/documents/UploadDocumentModal.tsx (dropzone multiple)
- Create: web/src/features/documents/batch/BatchUploadPage.tsx (skeleton only — replaced in later tasks)

Interfaces:
- Produces: route /documents/batch-upload?folder=<id|root>; navigation state {files: File[]}; UploadDocumentModal new optional prop onMultiple?: (files: File[]) => void.

  • [ ] Step 1: In analysis_cache.go change analysisCacheMax = 64analysisCacheMax = 256 and extend its comment: // 256 covers a full 100-file batch (analysis + retries) without self-eviction. Verify cd go && go build ./... && go vet ./....
  • [ ] Step 2: Skeleton page:
// web/src/features/documents/batch/BatchUploadPage.tsx
// Batch upload workbench: master-detail editor over an in-memory file queue.
// Files arrive via router state (in-memory only — a reload lands on an empty
// workbench by design; nothing is persisted until Create).
import { useLocation, useSearchParams } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { PageHeader } from '@/components/PageHeader' // read the file first: match how other pages render headers; use the app's actual header component or a plain <h2> matching DocumentsPage.
export function BatchUploadPage() {
  const { t } = useTranslation()
  const location = useLocation()
  const [params] = useSearchParams()
  const initialFiles = ((location.state as { files?: File[] } | null)?.files ?? []).slice(0, 100)
  const folderId = params.get('folder') === 'root' ? null : params.get('folder')
  return (
    <div className="page batchup">
      <h2 className="page__title">{t('newDoc.batchTitle')}</h2>
      <p className="muted">{initialFiles.length} file(s) · folder {folderId ?? '/'} (workbench lands in Task 3)</p>
    </div>
  )
}

(Read a sibling page first for the exact page-shell classes; match them.)
- [ ] Step 3: Route in App.tsx inside the <Route element={<AppShell />}> group next to /documents: <Route path="/documents/batch-upload" element={<BatchUploadPage />} /> + import.
- [ ] Step 4: Toolbar button in DocumentsPage.tsx (before the Upload button, kind="tertiary", icon DocumentMultiple_02 or Copy from @carbon/icons-react — check available icon names, DocumentAdd sibling): onClick={() => navigate(\/documents/batch-upload?folder=${currentFolderId ?? 'root'}`)}with labelt('newDoc.batchTitle'). - [ ] **Step 5:** Multi-drop handoff.UploadDocumentModal.tsx: add proponMultiple?: (files: File[]) => void; dropzonemultiple(removemultiple={false}/ setmultiple), and inonAddFiles:const files = addedFiles ?? []; if (files.length > 1 && onMultiple) { onMultiple(files); return } onFile(files[0] ?? null). InDocumentsPage.tsxpassonMultiple={(files) => { setUploadOpen(false); navigate(`/documents/batch-upload?folder=${currentFolderId ?? 'root'}`, { state: { files } }) }}. - [ ] **Step 6:** i18n: add to thenewDocgroup inen.ts:batchTitle: 'Batch upload',andid.ts:batchTitle: 'Unggah massal',. - [ ] **Step 7:** Verifycd web && npx tsc --noEmit && npx vite build. Manually reason: single-file drop in the modal must behave exactly as before. - [ ] **Step 8:** Commit:git add go/internal/httpapi/analysis_cache.go web/src/App.tsx web/src/features/documents/DocumentsPage.tsx web/src/features/documents/UploadDocumentModal.tsx web/src/features/documents/batch/BatchUploadPage.tsx web/src/i18n/locales/en.ts web/src/i18n/locales/id.ts && git commit -m "feat(web): batch upload route, entry points; analysis cache 256"`


Task 2: Batch state model

Files:
- Create: web/src/features/documents/batch/batchModel.ts

Interfaces:
- Produces (complete — later tasks import from here):

// web/src/features/documents/batch/batchModel.ts
// Pure state for the batch workbench: one row per file, no React, no IO.
import { type Classification } from '@/lib/classification'

export type AiStatus = 'queued' | 'analyzing' | 'ready' | 'no_text' | 'failed' | 'skipped'
export type CreateStatus = 'pending' | 'creating' | 'created' | 'create_failed'

export interface FolderSuggestion { folderId: string; path: string; score: number }

export interface BatchRow {
  key: string                       // stable client id (crypto.randomUUID())
  file: File
  title: string
  classification: Classification    // 'none' until user/AI sets it
  classSuggested: boolean
  docType: string
  formatId: string                  // '' = no numbering; seeded from default format by the page
  customVars: Record<string, string>
  folderId: string | null           // chosen destination (null = root)
  folderPath: string
  folderPicked: boolean             // true once user/AI explicitly set a destination
  suggestions: FolderSuggestion[]   // top-3 for this row
  ai: AiStatus
  aiTruncatedNote?: boolean
  analysisId: string
  summary: string                   // editable; '' when no analysis
  aiSummaryFrozen: string           // original AI summary (suggest signal; never edited)
  tags: { name: string; selected: boolean }[]
  create: CreateStatus
  createdDocId?: string
  createError?: string
}

export const BATCH_MAX = 100

export function newRow(file: File): BatchRow {
  const dot = file.name.lastIndexOf('.')
  return {
    key: crypto.randomUUID(), file,
    title: (dot > 0 ? file.name.slice(0, dot) : file.name).trim(),
    classification: 'none', classSuggested: false, docType: '',
    formatId: '', customVars: {},
    folderId: null, folderPath: '', folderPicked: false,
    suggestions: [], ai: 'queued', analysisId: '', summary: '', aiSummaryFrozen: '',
    tags: [], create: 'pending',
  }
}

// Mirror of the server's AddVersion allow-list (handlers_dms.go allowedUploadExt).
const ALLOWED_EXT = new Set(['pdf','png','jpg','jpeg','gif','webp','svg','bmp','tif','tiff','docx','xlsx','pptx','doc','xls','ppt','odt','ods','odp','rtf','txt','csv','md','markdown','html','htm'])
export function extAllowed(name: string): boolean {
  const dot = name.lastIndexOf('.')
  return dot > 0 && ALLOWED_EXT.has(name.slice(dot + 1).toLowerCase())
}

const CUSTOM_RE = /\{custom:([^}]+)\}/g
export function extractCustoms(pattern: string): string[] {
  const out: string[] = []; const seen = new Set<string>(); let m: RegExpExecArray | null
  while ((m = CUSTOM_RE.exec(pattern)) !== null) {
    const n = m[1]!.trim().toLowerCase()
    if (n && !seen.has(n)) { seen.add(n); out.push(n) }
  }
  return out
}

// A row's blockers (empty array = creatable). formatPatterns: formatId → pattern.
export function rowBlockers(row: BatchRow, formatPatterns: Map<string, string>): ('title' | 'customs' | 'folder')[] {
  const out: ('title' | 'customs' | 'folder')[] = []
  if (!row.title.trim()) out.push('title')
  const pattern = row.formatId ? formatPatterns.get(row.formatId) ?? '' : ''
  if (extractCustoms(pattern).some((n) => !(row.customVars[n] ?? '').trim())) out.push('customs')
  if (!row.folderPicked) out.push('folder')
  return out
}

export function creatable(row: BatchRow, formatPatterns: Map<string, string>): boolean {
  return row.create === 'pending' && rowBlockers(row, formatPatterns).length === 0
}

export function tally(rows: BatchRow[], formatPatterns: Map<string, string>) {
  return {
    total: rows.length,
    analyzing: rows.filter((r) => r.ai === 'queued' || r.ai === 'analyzing').length,
    needsInput: rows.filter((r) => r.create === 'pending' && rowBlockers(r, formatPatterns).length > 0).length,
    creatable: rows.filter((r) => creatable(r, formatPatterns)).length,
    created: rows.filter((r) => r.create === 'created').length,
    failed: rows.filter((r) => r.create === 'create_failed').length,
  }
}

Note the design decision baked in: folderPicked starts false — the page's origin folder is offered by FolderSuggestBox as "current folder" but per the spec each file needs an explicit destination (AI top-1 auto-set marks folderPicked=true; user picking current/browse does too). For core-only installs (no suggestions), the page seeds every row with the origin folder (folderPicked=true) so batches remain one-click — Task 3 wires this: if (!semanticOn) row.folderId = originFolderId; row.folderPath = originPath; row.folderPicked = true.

  • [ ] Step 1: Write the file exactly as above.
  • [ ] Step 2: Verify cd web && npx tsc --noEmit.
  • [ ] Step 3: Commit: git add web/src/features/documents/batch/batchModel.ts && git commit -m "feat(web): batch upload state model"

Task 3: Workbench UI (list + detail editor + preview)

Files:
- Create: BatchFileList.tsx, BatchDetailPanel.tsx, BatchPreviewModal.tsx (in web/src/features/documents/batch/)
- Rewrite: BatchUploadPage.tsx (real state + layout; queues arrive in Tasks 4-5)

Interfaces:
- Consumes: everything from batchModel.ts; useIdFormats, useActiveClassifications, FolderSuggestBox, useMe+moduleEnabled, ACCEPTED_UPLOAD_TYPES.
- Produces:
- BatchFileList({ rows, selectedKeys, onSelect(keys: string[], anchorKey: string), formatPatterns }) — selection SEMANTICS live in Task 6; for now plain click-select (single).
- BatchDetailPanel({ row, onChange(patch: Partial<BatchRow>), onRemove(), onPreview(), formats, classItems, aiOn, semanticOn, currentFolderId, currentFolderPath }) — controlled editor; onChange patches the row in page state.
- BatchPreviewModal({ file, onClose }) — passive Carbon Modal (no footer), blob URL created/revoked in an effect, PDF iframe / image / fallback exactly like UploadDocumentModal.tsx:200-215 (read it).
- Page state: const [rows, setRows] = useState<BatchRow[]>(...); patchRow(key, patch); addFiles(files: File[]) enforcing BATCH_MAX + extAllowed (rejects → toast/InlineNotification listing names); beforeunload effect while rows.some(r => r.create === 'pending' || r.create === 'creating').

  • [ ] Step 1: Read UploadDocumentModal.tsx fully — the detail panel reuses its form markup (Title TextInput, Classification Dropdown with CLASS_ITEMS, AI-panel block, Doc-ID Dropdown + custom TextInputs, FolderSuggestBox). Copy the JSX shapes; bind to row via onChange patches instead of local useState.
  • [ ] Step 2: BatchDetailPanel: single-row mode only (multi-select panel is Task 6). The AI panel block renders from row fields: ai === 'analyzing'|'queued' → InlineLoading; no_text/failed → muted note; ready → summary TextArea (rows 5, onChange({summary: v})) + tag chips toggling row.tags[i].selected. FolderSuggestBox per row:
<FolderSuggestBox
  query={{ title: row.title, filename: row.file.name, text: row.aiSummaryFrozen || undefined }}
  enabled
  currentFolderId={currentFolderId}
  currentFolderPath={currentFolderPath}
  value={{ folderId: row.folderId, path: row.folderPath }}
  onChange={(c) => onChange({ folderId: c.folderId, folderPath: c.path, folderPicked: true })}
/>
  • [ ] Step 3: BatchFileList: a scrollable <ul>; each row renders status dot (map ai/create → css class), title, destination line (row.folderPicked ? \→ ${row.folderPath || '/'}` : t('newDoc.batchNoFolder'), plus top score badge whenrow.suggestions[0]matched), warning badge fromrowBlockers,created ✓link (/documents/d/${row.createdDocId}). Click row →onSelect([row.key], row.key)`.
  • [ ] Step 4: BatchUploadPage layout: top bar (Add files hidden-input button honoring ACCEPTED_UPLOAD_TYPES, tally line from tally(), Create all (N) button disabled at N=0 — handler arrives Task 4) over a two-column flex (.batchup__cols: list 340px, detail flex-1). Initialize rows from router-state files via addFiles; seed origin folder per the Task 2 note when semantic is off.
  • [ ] Step 5: Verify tsc + vite build; commit feat(web): batch workbench UI (list, editor, preview).

Task 4: Create-all queue

Files:
- Create: web/src/features/documents/batch/useBatchCreate.ts
- Modify: BatchUploadPage.tsx (wire button + per-row retry)

Interfaces:
- Produces: useBatchCreate({ rows, patchRow, formatPatterns }) → { createAll(): void, retryRow(key: string): void, running: boolean }.

  • [ ] Step 1: Implementation (complete):
// useBatchCreate.ts — sequential creation queue, concurrency 2. Per row:
// create → uploadVersion(analysisId, summary) → tags. No rollback; failed rows retry.
import { useRef, useState } from 'react'
import { useCreateDocument } from '@/api/documents'
import { uploadVersion } from '@/api/files'
import { api } from '@/api/client'
import { creatable, type BatchRow } from './batchModel'

const CREATE_CONCURRENCY = 2

export function useBatchCreate(opts: {
  rows: BatchRow[]
  patchRow: (key: string, patch: Partial<BatchRow>) => void
  formatPatterns: Map<string, string>
}) {
  const createDocument = useCreateDocument()
  const [running, setRunning] = useState(false)
  const rowsRef = useRef(opts.rows)
  rowsRef.current = opts.rows

  const createOne = async (row: BatchRow) => {
    opts.patchRow(row.key, { create: 'creating', createError: undefined })
    try {
      const id = await createDocument.mutateAsync({
        title: row.title.trim(),
        folderId: row.folderId,
        classification: row.classification,
        docType: row.docType.trim(),
        formatId: row.formatId,
        customVars: Object.fromEntries(Object.entries(row.customVars).map(([k, v]) => [k, v.trim()])),
      })
      if (!id) throw new Error('no id')
      try {
        await uploadVersion(id, row.file, row.analysisId ? { analysisId: row.analysisId, summary: row.summary.trim() || undefined } : undefined)
      } catch {
        // Stub exists with no content — surface as failure so the user retries the
        // version from the doc page; do NOT auto-delete (matches single-file flow).
        opts.patchRow(row.key, { create: 'create_failed', createdDocId: id, createError: 'upload' })
        return
      }
      const tags = row.tags.filter((t) => t.selected).map((t) => t.name)
      if (tags.length > 0) {
        try { await api.PUT('/api/v1/documents/{docID}/tags', { params: { path: { docID: id } }, body: { tags } }) } catch { /* tags applied later from detail */ }
      }
      opts.patchRow(row.key, { create: 'created', createdDocId: id })
    } catch (e) {
      opts.patchRow(row.key, { create: 'create_failed', createError: e instanceof Error ? e.message : 'failed' })
    }
  }

  const drain = async (keys: string[]) => {
    setRunning(true)
    const queue = [...keys]
    const workers = Array.from({ length: CREATE_CONCURRENCY }, async () => {
      for (;;) {
        const key = queue.shift()
        if (!key) return
        const row = rowsRef.current.find((r) => r.key === key)
        if (row) await createOne(row)
      }
    })
    await Promise.all(workers)
    setRunning(false)
  }

  return {
    running,
    createAll: () => { void drain(rowsRef.current.filter((r) => creatable(r, opts.formatPatterns)).map((r) => r.key)) },
    retryRow: (key: string) => {
      const row = rowsRef.current.find((r) => r.key === key)
      if (!row || row.create !== 'create_failed') return
      // A row that failed AFTER create (upload leg) must not create a second stub.
      if (row.createdDocId) {
        opts.patchRow(key, { create: 'creating' })
        void uploadVersion(row.createdDocId, row.file, row.analysisId ? { analysisId: row.analysisId, summary: row.summary.trim() || undefined } : undefined)
          .then(() => opts.patchRow(key, { create: 'created' }))
          .catch(() => opts.patchRow(key, { create: 'create_failed', createError: 'upload' }))
        return
      }
      void drain([key])
    },
  }
}

NOTE the analysisId nuance: the cache entry is consume-on-use — after a successful uploadVersion redemption it is gone; a retry after an upload failure may find it expired/consumed, which silently falls back to the async pipeline (already-built server semantics). Correct, no special handling.
- [ ] Step 2: Wire in the page: Create all (N) calls createAll; list rows with create_failed show a Retry link calling retryRow(key); disable Add/Remove while running.
- [ ] Step 3: tsc + vite build; commit feat(web): batch create-all queue with per-row retry.


Task 5: AI pipeline queue

Files:
- Create: web/src/features/documents/batch/useBatchAi.ts
- Modify: BatchUploadPage.tsx (wire; budget banner; Accept-all button)

Interfaces:
- Consumes: analyzeUpload, useUploadAiSettings, useSuggestFolder, i18n.language.
- Produces: useBatchAi({ rows, patchRow, aiOn, semanticOn }) → { budgetExhausted: boolean } — self-schedules: an effect watches for rows with ai === 'queued' and drains them at concurrency 3. Also acceptAllSuggestions() helper on the page (sets each row's folder to suggestions[0] where present).

  • [ ] Step 1: Implementation (complete):
// useBatchAi.ts — per-row AI pipeline: analyze (summary/class/tags) then content-aware
// folder suggestion; top-1 auto-files the row. Concurrency 3. Soft-fail everywhere; a
// budget 429 stops AI for remaining rows (uploads unaffected).
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { analyzeUpload, useUploadAiSettings } from '@/api/ai'
import { useSuggestFolder } from '@/api/semantic'
import { type BatchRow } from './batchModel'

const AI_CONCURRENCY = 3

export function useBatchAi(opts: {
  rows: BatchRow[]
  patchRow: (key: string, patch: Partial<BatchRow>) => void
  aiOn: boolean
  semanticOn: boolean
  classIds: string[] // active classification registry codes (validate AI suggestion)
}) {
  const { i18n } = useTranslation()
  const uploadAi = useUploadAiSettings(opts.aiOn)
  const suggest = useSuggestFolder()
  const [budgetExhausted, setBudgetExhausted] = useState(false)
  const inFlight = useRef(0)
  const stopped = useRef(false)
  const rowsRef = useRef(opts.rows)
  rowsRef.current = opts.rows

  const analyzeEnabled = opts.aiOn && (uploadAi.data ? uploadAi.data.analyzeEnabled : true)
  const mode = analyzeEnabled ? (uploadAi.data?.suggestMode ?? 'after_analysis') : 'immediate'

  const suggestFor = async (row: BatchRow, text: string | undefined) => {
    if (!opts.semanticOn) return
    try {
      const res = await suggest.mutateAsync({ title: row.title, filename: row.file.name, text, k: 3 })
      const top = res[0]
      opts.patchRow(row.key, {
        suggestions: res,
        // Auto-file on the top suggestion unless the user already picked a folder.
        ...(top && !rowsRef.current.find((r) => r.key === row.key)?.folderPicked
          ? { folderId: top.folderId, folderPath: top.path, folderPicked: true }
          : {}),
      })
    } catch { /* suggestions are decoration */ }
  }

  const processOne = async (row: BatchRow) => {
    // immediate mode (or analysis unavailable): name-only suggestion, no analysis pass.
    if (!analyzeEnabled || stopped.current) {
      opts.patchRow(row.key, { ai: 'skipped' })
      await suggestFor(row, undefined)
      return
    }
    if (mode === 'both') void suggestFor(row, undefined) // early name-only pass
    opts.patchRow(row.key, { ai: 'analyzing' })
    try {
      const res = await analyzeUpload(row.file, row.title, i18n.language)
      if (res.disabled) { opts.patchRow(row.key, { ai: 'skipped' }); await suggestFor(row, undefined); return }
      if (res.noText || !res.analysisId) { opts.patchRow(row.key, { ai: 'no_text' }); await suggestFor(row, undefined); return }
      const classOk = res.classification !== '' && opts.classIds.includes(res.classification)
      const current = rowsRef.current.find((r) => r.key === row.key)
      opts.patchRow(row.key, {
        ai: 'ready',
        analysisId: res.analysisId,
        summary: res.summary,
        aiSummaryFrozen: res.summary,
        tags: res.tags.map((name) => ({ name, selected: true })),
        ...(classOk && current?.classification === 'none'
          ? { classification: res.classification as BatchRow['classification'], classSuggested: true }
          : {}),
      })
      if (mode !== 'immediate') await suggestFor(row, res.summary)
    } catch (e) {
      const code = (e as Error & { code?: string }).code
      if (code === 'ai.budget_exhausted') {
        stopped.current = true
        setBudgetExhausted(true)
        opts.patchRow(row.key, { ai: 'skipped' })
        await suggestFor(row, undefined)
        return
      }
      opts.patchRow(row.key, { ai: 'failed' })
      await suggestFor(row, undefined)
    }
  }

  useEffect(() => {
    if (inFlight.current >= AI_CONCURRENCY) return
    const next = opts.rows.filter((r) => r.ai === 'queued').slice(0, AI_CONCURRENCY - inFlight.current)
    for (const row of next) {
      inFlight.current += 1
      opts.patchRow(row.key, { ai: 'analyzing' })
      void processOne(row).finally(() => { inFlight.current -= 1 })
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [opts.rows])

  return { budgetExhausted }
}

CHECK during implementation: analyzeUpload throws Error & {code} via toError — confirm the 429 problem's code is ai.budget_exhausted (grep budget_exhausted in go/internal/ai/app/service.go); the effect double-marks analyzing (in loop AND in processOne) — harmless idempotent patch, keep both to prevent re-pick.
- [ ] Step 2: Page wiring: banner (InlineNotification kind="warning") when budgetExhausted (t('newDoc.batchBudget')); top-bar Accept all suggestions sets every row with suggestions[0] to it (folderPicked: true); rows in mode==='after_analysis' show the existing waiting semantics naturally (suggestion arrives with analysis — no extra UI needed beyond the status dot).
- [ ] Step 3: tsc + vite build; commit feat(web): batch AI pipeline (analyze + auto-filing suggestions).


Task 6: Multi-select + batch operations

Files:
- Modify: BatchFileList.tsx (selection semantics), BatchDetailPanel.tsx (N-selected mode), BatchUploadPage.tsx (selection state + action bar + Defaults menu)

Interfaces:
- Page owns selectedKeys: Set<string> + anchor: string | null. List computes clicks: plain = {key} + anchor=key; ctrl/cmd = toggle; shift = contiguous range from anchor in current order; Ctrl/Cmd-A (keydown on the focused list container, tabIndex={0}) = all; Escape = clear.
- Bulk ops (page-level functions applied over selectedKeys): removeSelected(), setClassificationFor(keys, c), setFormatFor(keys, formatId), addTagFor(keys, tag) (append {name, selected:true} if absent), setFolderFor(keys, folderId, path) (via one MoveModal), acceptSuggestionsFor(keys), retryFailedFor(keys).

  • [ ] Step 1: Selection handling in BatchFileList row onClick={(e) => onRowClick(row.key, e.ctrlKey || e.metaKey, e.shiftKey)} + checkbox column mirroring membership; container tabIndex={0} onKeyDown for ctrl-A/Escape.
  • [ ] Step 2: Selection action bar: when selectedKeys.size >= 2 render a .batchup__selbar div (hand-rolled, no Carbon DataTable): count label + Buttons/OverflowMenus for the seven ops (classification via Dropdown, format via Dropdown, tag via small TextInput+Add, folder via MoveModal).
  • [ ] Step 3: BatchDetailPanel multi mode: when ≥2 selected show t('newDoc.batchSelected', {count}) + the same op controls; no single-file fields.
  • [ ] Step 4: Defaults ▾ (top bar OverflowMenu): same three setters applied to ALL rows (classification, formatId, add tag).
  • [ ] Step 5: tsc + vite build; commit feat(web): batch multi-select + selection action bar + defaults.

Task 7: i18n + styles

Files:
- Modify: web/src/i18n/locales/en.ts + id.ts (newDoc group), web/src/styles/app.css

  • [ ] Step 1: Keys (en / id):
batchTitle: 'Batch upload' / 'Unggah massal'
batchAdd: 'Add files' / 'Tambah berkas'
batchAcceptAll: 'Accept all suggestions' / 'Terima semua saran'
batchDefaults: 'Defaults' / 'Nilai bawaan'
batchCreateAll: 'Create all ({{count}})' / 'Buat semua ({{count}})'
batchTally: '{{total}} files · {{analyzing}} analyzing · {{needsInput}} needs input · {{created}} created' / '{{total}} berkas · {{analyzing}} dianalisis · {{needsInput}} perlu isian · {{created}} dibuat'
batchNoFolder: 'No folder yet' / 'Belum ada folder'
batchNeedsInput: 'Needs input' / 'Perlu isian'
batchBudget: 'AI token budget exhausted — remaining files continue without suggestions.' / 'Anggaran token AI habis - berkas berikutnya lanjut tanpa saran.'
batchSelected: '{{count}} files selected' / '{{count}} berkas dipilih'
batchRemove: 'Remove from queue' / 'Hapus dari antrean'
batchRetry: 'Retry' / 'Coba lagi'
batchRejected: 'Not added (type not allowed or over the 100-file limit): {{names}}' / 'Tidak ditambahkan (jenis tidak diizinkan atau melebihi batas 100 berkas): {{names}}'
batchLeaveWarn: (used only by beforeunload  browser shows its own text; key not needed)

Match the file's nested structure/quoting; drop the batchLeaveWarn line (documented as unnecessary).
- [ ] Step 2: Styles: .batchup__cols (flex, gap 1.5rem), .batchup__list (flex 0 0 340px, overflow-y auto, max-height calc(100vh - 220px)), .batchup__row (+ --selected, status-dot modifiers reusing muted/green/red tokens), .batchup__selbar (sticky bar, layer background, gap 0.5rem), .batchup__detail (flex 1, min-width 0 — reuse .upload__ai styles for the AI block as-is). Dual-theme: use var(--cds-*) tokens only (no hardcoded palette — see the header-search lesson).
- [ ] Step 3: tsc + vite build; commit feat(web): batch upload i18n + styles.


Task 8: Deploy + e2e + click-through

  • [ ] Step 1: Deploy: docker compose -f deploy/docker-compose.yml --env-file deploy/mekari.env up -d --build obscura web; assert modules via dev-login (Global Constraints).
  • [ ] Step 2: Creation-path e2e (scripted curl, scratchpad dir; TOKEN via dev-login): loop 10 mixed files (6 generated text files with distinct topics, 1 reportlab 12-page PDF for the truncated path, 1 image, 2 docx if trivially available else text): per file analyze-upload (capture analysis_id) → create in a per-file TARGET folder (create 3 scratch folders first) → versions with analysis_id + summary=EDITED <n> → tags PUT. Assert per file: document_enrichment.summary = 'EDITED <n>', length(content_text) > 0, tags present (SELECT tags FROM documents — check column name first), and the 3 folders each received docs. One file uses a bogus analysis_id → still 201. Then delete+purge all e2e docs + folders; SELECT count(*) asserts 0 residue.
  • [ ] Step 3: UI click-through checklist (user does in browser; list in final report): multi-drop 3 files into the upload modal → lands on workbench; rows analyze and auto-file with distinct suggestions; shift-select 2 → selection bar → set classification; remove one via ctrl-select; preview popup; Create all → rows turn to links; created docs' AI Insights show the edited summaries; core-only sanity: temporarily unavailable (skip — needs license swap; reason it from code instead).
  • [ ] Step 4: Final commit of any e2e fixes; report commit list.

Self-review notes (done at plan time)

  • Spec coverage: entry points (T1), cap+allowlist+beforeunload (T2/T3), master-detail+preview (T3), creatable-subset Create-all + retry + no-rollback (T4), per-file auto-filing + suggest-mode honor + budget banner + accept-all (T5), selection semantics + 7 ops + Defaults▾ + N-selected panel (T6), i18n/styles dual-theme (T7), e2e (T8). Cache bump (T1). Core-only degradation: T2 note (origin-folder seeding) + aiOn/semanticOn gates in T3/T5.
  • Type consistency: BatchRow/rowBlockers/creatable/tally defined once in T2 and consumed by name in T3-T6; useBatchCreate/useBatchAi signatures declared in their tasks' Interfaces blocks.
  • Known judgment calls: upload-leg failure keeps the created stub and retries only the version (matches single-file "stub created; retry from detail" philosophy); folderPicked gating prevents AI from overriding a human choice; suggestion calls also run for skipped/no-text rows (name-only) so every row gets filing help.