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-logindirector@obscura.local, host port 38080). - Commit per task on
main; do NOT push unless asked; NEVERgit 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}.tsnestednewDoc:{}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}thenDELETE /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.gochangeanalysisCacheMax = 64→analysisCacheMax = 256and extend its comment:// 256 covers a full 100-file batch (analysis + retries) without self-eviction.Verifycd 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.tsxfully — the detail panel reuses its form markup (Title TextInput, Classification Dropdown withCLASS_ITEMS, AI-panel block, Doc-ID Dropdown + custom TextInputs, FolderSuggestBox). Copy the JSX shapes; bind torowviaonChangepatches 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 togglingrow.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 (mapai/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:
BatchUploadPagelayout: top bar (Add fileshidden-input button honoringACCEPTED_UPLOAD_TYPES, tally line fromtally(),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 viaaddFiles; 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
BatchFileListrowonClick={(e) => onRowClick(row.key, e.ctrlKey || e.metaKey, e.shiftKey)}+ checkbox column mirroring membership; containertabIndex={0}onKeyDownfor ctrl-A/Escape. - [ ] Step 2: Selection action bar: when
selectedKeys.size >= 2render a.batchup__selbardiv (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 viaMoveModal). - [ ] Step 3:
BatchDetailPanelmulti mode: when ≥2 selected showt('newDoc.batchSelected', {count})+ the same op controls; no single-file fields. - [ ] Step 4:
Defaults ▾(top barOverflowMenu): 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) →versionswith 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/tallydefined once in T2 and consumed by name in T3-T6;useBatchCreate/useBatchAisignatures 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);
folderPickedgating prevents AI from overriding a human choice; suggestion calls also run for skipped/no-text rows (name-only) so every row gets filing help.