New Document experience redesign — 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: Split document creation into two clear entry points (Upload / Write), make the upload modal drag-drop-first with an autofilled title and a readable multi-suggestion folder box, and reframe the write flow so authoring goes straight to the editor and metadata + filing (Save = PDF-but-editable / Save as draft = HTML) are collected in a new Save modal with a deferred Document ID.
Architecture: Frontend-only Phase A (a reusable FolderSuggestBox, a redesigned upload modal, a write-only modal, split toolbar). Phase B adds the write reframe (direct-to-editor unfiled drafts, a SaveDocumentModal, single editor Save button) plus two small backend additions (assign-Document-ID-to-existing-doc endpoint; an editable flag on the documents list). Reuses the existing TipTap editor, the finalize→PDF pipeline (already takes folderId), versions-by-MIME, and the semantic suggest endpoint — not a rebuild.
Tech Stack: React + Carbon (web/src), TanStack Query, openapi-fetch typed client, react-i18next (en/id parity tsc-enforced); Go modular monolith (go/internal, chi, pgx), api/openapi.yaml → npm run gen:api.
Working discipline (applies to EVERY task)
- NEVER run
go test(test DSN = live demo Postgres). Verify Go viacd go && go build ./... && go vet ./.... - Verify frontend via
cd web && npx tsc --noEmit && npx vite build. Runnpm run gen:apiafter anyapi/openapi.yamlchange. i18n en/id parity is tsc-enforced — add both locales. - Deploy only from repo root:
docker compose -f deploy/docker-compose.yml --env-file deploy/mekari.env up -d --build. After every deploy assert/me enabled_modulesis['ai','correspondence','esign','semantic','watermarking']and the demo is intact; clean up any test docs/folders you create. - Commit per task locally on
main. Do NOT push unless asked. FolderSuggestBoxmust degrade gracefully whensemanticis unlicensed (no AI rows; current-folder + browse remain).- Deferred Document ID must reject re-assignment (immutability).
- Upload the plan/spec
.mdviacurl -F "file=@<path>.md" https://x056.think.val.id/uploadif you modify them.
Post-deploy assertion snippet (reuse verbatim)
TOKEN=$(curl -s -XPOST localhost:38080/api/v1/auth/dev-login -H 'content-type: application/json' -d '{"email":"admin@obscura.local"}' | python3 -c 'import sys,json;print(json.load(sys.stdin)["token"])')
curl -s localhost:38080/api/v1/me -H "authorization: Bearer $TOKEN" | python3 -c 'import sys,json;print(sorted(json.load(sys.stdin)["enabled_modules"]))'
File Structure
Phase A (frontend only):
- Create web/src/features/documents/FolderSuggestBox.tsx — the reusable folder-suggestions box (AI top-3 + current folder + browse; auto-run; degrades).
- Create web/src/features/documents/UploadDocumentModal.tsx — drag-drop-first upload modal that expands into the metadata form + FolderSuggestBox; autofills title.
- Modify web/src/features/documents/NewDocumentModal.tsx → strip to write-only (title/classification/id/customs → editor); no file, no mode toggle, no folder box.
- Modify web/src/features/documents/DocumentsPage.tsx — split the toolbar button into Upload Document + Write Document; the upload submit files into the selected folder.
- Modify web/src/i18n/locales/en.ts + id.ts — new keys (newDoc.uploadTitle, newDoc.writeTitle, drop-zone, folder-box strings).
Phase B (frontend + small backend):
- Modify web/src/features/documents/DocumentsPage.tsx — Write button creates an unfiled ID-less draft → navigates straight to the editor (drop the write-only modal).
- Create web/src/features/documents/SaveDocumentModal.tsx — collects title/classification/Document-ID/folder (reuses FolderSuggestBox); Save / Save as draft.
- Modify web/src/features/documents/TextEditorView.tsx — replace the two Save/Finalize buttons with one Save → SaveDocumentModal; wire Save (finalize+folder) and Save-as-draft (HTML+move).
- Modify web/src/api/files.ts + web/src/api/documents.ts — assignDocumentId call + editable in the list mapping.
- Modify web/src/components/DocFlags.tsx + web/src/api/types.ts — add the editable flag/icon; version labels in the versions tab.
- Backend: go/internal/dms/app/service.go (AssignReference) + go/internal/dms/adapters/* (SetDocumentReference) + go/internal/httpapi/handlers_dms_* (assign-id handler) + handlers_dms_nav.go ListDocuments (editable_ids) + api/openapi.yaml.
PHASE A — Upload redesign + folder box + split toolbar (frontend only, shippable)
Task A1: i18n keys
Files:
- Modify: web/src/i18n/locales/en.ts (the newDoc.* block, ~lines 126-146)
- Modify: web/src/i18n/locales/id.ts (mirror)
- [ ] Step 1: Add keys to
en.tsinside the existingnewDocobject (keep the existing keys; add these):
uploadTitle: 'Upload document',
writeTitle: 'Write document',
dropHint: 'Drag a file here, or browse',
dropBrowse: 'browse',
titleFromFile: 'Title (from file name — edit if needed)',
folderBoxTitle: 'Where should this go?',
folderCurrent: 'Current folder',
folderBrowse: 'Browse for another folder…',
folderSuggesting: 'Finding folders…',
folderMatch: '{{pct}}% match',
folderNone: 'No suggestions — choose a folder',
- [ ] Step 2: Add the SAME keys to
id.ts(Indonesian) inside itsnewDocobject:
uploadTitle: 'Unggah dokumen',
writeTitle: 'Tulis dokumen',
dropHint: 'Seret berkas ke sini, atau telusuri',
dropBrowse: 'telusuri',
titleFromFile: 'Judul (dari nama berkas — ubah bila perlu)',
folderBoxTitle: 'Simpan ke mana?',
folderCurrent: 'Folder saat ini',
folderBrowse: 'Telusuri folder lain…',
folderSuggesting: 'Mencari folder…',
folderMatch: '{{pct}}% cocok',
folderNone: 'Tidak ada saran — pilih folder',
- [ ] Step 3: Verify + commit
cd /home/efran/remote-development/obscura/web && npx tsc --noEmit
cd /home/efran/remote-development/obscura
git add web/src/i18n/locales/en.ts web/src/i18n/locales/id.ts
git commit -m "feat(web): i18n keys for the New Document redesign"
Expected: tsc clean (parity enforced).
Task A2: FolderSuggestBox component
Files:
- Create: web/src/features/documents/FolderSuggestBox.tsx
- Reference: web/src/api/semantic.ts (useSuggestFolder → FolderSuggestion[] {folderId,path,score}), web/src/lib/nav.ts (moduleEnabled), web/src/api/me.ts (useMe), web/src/features/documents/MoveModal.tsx (the browse tree, reused).
- [ ] Step 1: Write the component
// A reusable "where should this go?" folder chooser: AI folder suggestions (semantic module),
// the current folder (always offered), and a Browse fallback (the existing MoveModal tree).
// Auto-runs the suggestion when its query settles; degrades to current-folder + browse when the
// semantic module is not licensed. Controlled: the parent owns the selected folder id + path.
import { useEffect, useState } from 'react'
import { RadioButtonGroup, RadioButton, InlineLoading, Button } from '@carbon/react'
import { useTranslation } from 'react-i18next'
import { useMe } from '@/api/me'
import { moduleEnabled } from '@/lib/nav'
import { useSuggestFolder, type FolderSuggestion } from '@/api/semantic'
import { MoveModal } from './MoveModal'
const CURRENT = '__current__'
const BROWSE = '__browse__'
export interface FolderChoice {
folderId: string | null
path: string
}
interface Props {
// The suggestion query. For upload: {title, filename}; for write: {title, text}.
query: { title?: string; text?: string; filename?: string }
// Whether to run the suggestion at all (e.g. only once a file is chosen).
enabled: boolean
currentFolderId: string | null
currentFolderPath: string
value: FolderChoice
onChange: (choice: FolderChoice) => void
}
export function FolderSuggestBox({ query, enabled, currentFolderId, currentFolderPath, value, onChange }: Props) {
const { t } = useTranslation()
const me = useMe()
const semanticEnabled = moduleEnabled(me.data?.enabledModules, 'semantic')
const suggest = useSuggestFolder()
const [rows, setRows] = useState<FolderSuggestion[]>([])
const [browseOpen, setBrowseOpen] = useState(false)
// Serialize the query so the effect only re-runs when the meaningful inputs change.
const qKey = `${query.title ?? ''}