From c9d1fdf86e3b854fe00489e2b0f4fcd7f47cd899 Mon Sep 17 00:00:00 2001 From: Hqzdev Date: Wed, 1 Jul 2026 13:19:40 +0500 Subject: [PATCH] feat: add document action controls --- .../web/documents/[documentId]/jobs/route.ts | 18 +++++ apps/web/app/globals.css | 34 +++++++++- apps/web/components/Workspace.tsx | 67 ++++++++++++++++++- apps/web/lib/api.ts | 20 ++++++ 4 files changed, 137 insertions(+), 2 deletions(-) create mode 100644 apps/web/app/api/web/documents/[documentId]/jobs/route.ts diff --git a/apps/web/app/api/web/documents/[documentId]/jobs/route.ts b/apps/web/app/api/web/documents/[documentId]/jobs/route.ts new file mode 100644 index 0000000..cf08e9c --- /dev/null +++ b/apps/web/app/api/web/documents/[documentId]/jobs/route.ts @@ -0,0 +1,18 @@ +import { NextRequest } from "next/server"; +import { proxyPrivate } from "@/lib/server-gateway"; + +type RouteContext = { + params: Promise<{ + documentId: string; + }>; +}; + +export async function GET(request: NextRequest, context: RouteContext) { + const { documentId } = await context.params; + + return proxyPrivate({ + method: "GET", + path: `/api/documents/${documentId}/jobs`, + request + }); +} diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index b5897c0..3be8cd2 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -147,6 +147,22 @@ button { border-color: var(--ink); } +.btn-small { + border-radius: 8px; + font-size: 12px; + min-height: 34px; + padding: 7px 10px; +} + +.btn-ghost.danger { + color: #9b2c0f; +} + +.btn-ghost.danger:hover { + border-color: #ff9b7a; + color: #7c210b; +} + .nav { background: rgba(243, 241, 231, 0.84); backdrop-filter: blur(14px); @@ -1049,7 +1065,7 @@ button { border-bottom: 1px solid var(--line); display: grid; gap: 12px; - grid-template-columns: 1.4fr 0.8fr 0.8fr 0.8fr; + grid-template-columns: minmax(0, 1.4fr) 0.8fr 0.8fr 0.5fr auto; padding: 14px 16px; transition: background 160ms ease; } @@ -1062,6 +1078,22 @@ button { border-bottom: 0; } +.doc-row strong, +.doc-row span { + min-width: 0; +} + +.doc-row strong { + overflow-wrap: anywhere; +} + +.doc-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + justify-content: flex-end; +} + .status-badge { border-radius: 999px; display: inline-flex; diff --git a/apps/web/components/Workspace.tsx b/apps/web/components/Workspace.tsx index 5b828b4..afae2b8 100644 --- a/apps/web/components/Workspace.tsx +++ b/apps/web/components/Workspace.tsx @@ -5,7 +5,7 @@ import { useRouter } from "next/navigation"; import { FormEvent, useEffect, useMemo, useState } from "react"; import { AnimatedNumber } from "@/components/AnimatedNumber"; import { Logo } from "@/components/Chrome"; -import { ApiError, AnswerResponse, DocumentSummary, TenantSummary, UserSummary, askQuestion, getMe, listDocuments, logout, queueDocument } from "@/lib/api"; +import { ApiError, AnswerResponse, DocumentSummary, TenantSummary, UserSummary, askQuestion, deleteDocument, getMe, listDocumentJobs, listDocuments, logout, queueDocument, retryIngestionJob } from "@/lib/api"; const navItems = ["Chat", "Documents", "Usage", "Settings"]; @@ -14,6 +14,11 @@ type WorkspaceStatus = { message: string; }; +type DocumentAction = { + documentId: string; + type: "delete" | "retry"; +}; + export function Workspace() { const router = useRouter(); const [user, setUser] = useState(null); @@ -24,6 +29,7 @@ export function Workspace() { const [isLoading, setIsLoading] = useState(true); const [isUploading, setIsUploading] = useState(false); const [isAsking, setIsAsking] = useState(false); + const [documentAction, setDocumentAction] = useState(null); const usageMetrics = useMemo( () => [ @@ -129,6 +135,47 @@ export function Workspace() { } } + async function handleDeleteDocument(document: DocumentSummary) { + if (!confirm(`Delete "${document.title}" from this workspace?`)) { + return; + } + + setStatus(null); + setDocumentAction({ documentId: document.id, type: "delete" }); + + try { + await deleteDocument(document.id); + setDocuments((currentDocuments) => currentDocuments.filter((item) => item.id !== document.id)); + setStatus({ tone: "success", message: "Document deleted." }); + } catch (error) { + setStatus({ tone: "error", message: resolveWorkspaceError(error) }); + } finally { + setDocumentAction(null); + } + } + + async function handleRetryDocument(document: DocumentSummary) { + setStatus(null); + setDocumentAction({ documentId: document.id, type: "retry" }); + + try { + const jobs = await listDocumentJobs(document.id); + const failedJob = jobs.find((job) => job.status === "failed"); + + if (!failedJob) { + throw new Error("No failed ingestion job found for this document."); + } + + await retryIngestionJob(document.id, failedJob.id); + setDocuments(await listDocuments()); + setStatus({ tone: "success", message: "Ingestion retry queued." }); + } catch (error) { + setStatus({ tone: "error", message: resolveWorkspaceError(error) }); + } finally { + setDocumentAction(null); + } + } + async function handleLogout() { await logout(); router.replace("/login"); @@ -233,6 +280,16 @@ export function Workspace() { {document.content_type} {document.status} v{document.current_version} +
+ {document.status === "failed" ? ( + + ) : null} + +
)) : (
@@ -257,6 +314,14 @@ export function Workspace() { ); } +function isDocumentAction(action: DocumentAction | null, documentId: string, type: DocumentAction["type"]) { + return action?.documentId === documentId && action.type === type; +} + +function isDocumentActionActive(action: DocumentAction | null, documentId: string) { + return action?.documentId === documentId; +} + function getFormValue(form: FormData, key: string) { const value = form.get(key); diff --git a/apps/web/lib/api.ts b/apps/web/lib/api.ts index 7b99519..508cba6 100644 --- a/apps/web/lib/api.ts +++ b/apps/web/lib/api.ts @@ -72,6 +72,20 @@ export type DocumentSummary = { updated_at: string; }; +export type IngestionJobSummary = { + id: string; + tenant_id: string; + document_id: string; + document_version_id: string; + status: string; + attempts: number; + error_code: string | null; + error_message: string | null; + queued_at: string; + started_at: string | null; + finished_at: string | null; +}; + export type CitationResponse = { document_id: string; document_title: string; @@ -130,6 +144,12 @@ export async function listDocuments() { }); } +export async function listDocumentJobs(documentId: string) { + return request(`/api/web/documents/${documentId}/jobs`, { + method: "GET" + }); +} + export async function askQuestion(payload: QuestionRequest) { return request("/api/web/questions", { method: "POST",