diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index 3be8cd2..4e1c997 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -1074,6 +1074,11 @@ button { background: #f5f9ef; } +.doc-row.selected { + background: #eff6ec; + box-shadow: inset 3px 0 0 var(--accent); +} + .doc-row:last-child { border-bottom: 0; } @@ -1094,6 +1099,81 @@ button { justify-content: flex-end; } +.document-detail { + background: #f7f8f0; + border: 1px solid var(--line); + border-radius: 14px; + margin-top: 16px; + padding: 18px; +} + +.document-detail-head { + align-items: start; + display: flex; + gap: 16px; + justify-content: space-between; +} + +.document-detail-head h3 { + font-size: 20px; + margin: 0; + overflow-wrap: anywhere; +} + +.job-list { + display: grid; + gap: 10px; + margin-top: 16px; +} + +.job-row { + background: var(--surface); + border: 1px solid var(--line); + border-radius: 10px; + display: grid; + gap: 10px; + grid-template-columns: minmax(0, 1.5fr) auto auto; + padding: 12px; +} + +.job-row div { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: 8px; + min-width: 0; +} + +.job-row strong { + color: var(--muted); + font-size: 11px; + min-width: 0; + overflow-wrap: anywhere; +} + +.job-row span { + color: var(--muted); + font-size: 12px; +} + +.job-row p { + color: #9b2c0f; + font-size: 12px; + grid-column: 1 / -1; + margin: 0; + overflow-wrap: anywhere; +} + +.job-empty { + background: var(--surface); + border: 1px dashed var(--line); + border-radius: 10px; + color: var(--muted); + font-size: 13px; + margin-top: 16px; + padding: 14px; +} + .status-badge { border-radius: 999px; display: inline-flex; @@ -1114,11 +1194,37 @@ button { color: #8a5b00; } +.status-badge.uploaded { + background: #f0eee4; + color: #5a655e; +} + .status-badge.failed { background: #ffe1d8; color: #b43218; } +.status-badge.queued { + background: #eaf0ff; + color: #3159bb; +} + +.status-badge.running { + background: #fff2ce; + color: #8a5b00; +} + +.status-badge.completed { + background: #e0f7e7; + color: #0b7d52; +} + +.status-badge.cancelled, +.status-badge.deleted { + background: #f0eee4; + color: #5a655e; +} + .cursor-layer { inset: 0; pointer-events: none; @@ -1270,6 +1376,14 @@ button { grid-template-columns: 1fr; } + .doc-actions { + justify-content: flex-start; + } + + .job-row { + grid-template-columns: 1fr; + } + .feature-card.wide { grid-column: span 1; } diff --git a/apps/web/components/Workspace.tsx b/apps/web/components/Workspace.tsx index afae2b8..1e8d389 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, deleteDocument, getMe, listDocumentJobs, listDocuments, logout, queueDocument, retryIngestionJob } from "@/lib/api"; +import { ApiError, AnswerResponse, DocumentSummary, IngestionJobSummary, TenantSummary, UserSummary, askQuestion, deleteDocument, getMe, listDocumentJobs, listDocuments, logout, queueDocument, retryIngestionJob } from "@/lib/api"; const navItems = ["Chat", "Documents", "Usage", "Settings"]; @@ -30,6 +30,15 @@ export function Workspace() { const [isUploading, setIsUploading] = useState(false); const [isAsking, setIsAsking] = useState(false); const [documentAction, setDocumentAction] = useState(null); + const [selectedDocumentId, setSelectedDocumentId] = useState(null); + const [selectedDocumentJobs, setSelectedDocumentJobs] = useState([]); + const [isLoadingJobs, setIsLoadingJobs] = useState(false); + const [jobHistoryError, setJobHistoryError] = useState(null); + + const selectedDocument = useMemo( + () => documents.find((document) => document.id === selectedDocumentId) ?? null, + [documents, selectedDocumentId] + ); const usageMetrics = useMemo( () => [ @@ -102,7 +111,8 @@ export function Workspace() { content } ); - setDocuments(await listDocuments()); + const nextDocuments = await listDocuments(); + setDocuments(nextDocuments); setStatus({ tone: "success", message: "Document queued for indexing." }); event.currentTarget.reset(); } catch (error) { @@ -146,6 +156,11 @@ export function Workspace() { try { await deleteDocument(document.id); setDocuments((currentDocuments) => currentDocuments.filter((item) => item.id !== document.id)); + if (selectedDocumentId === document.id) { + setSelectedDocumentId(null); + setSelectedDocumentJobs([]); + setJobHistoryError(null); + } setStatus({ tone: "success", message: "Document deleted." }); } catch (error) { setStatus({ tone: "error", message: resolveWorkspaceError(error) }); @@ -168,6 +183,9 @@ export function Workspace() { await retryIngestionJob(document.id, failedJob.id); setDocuments(await listDocuments()); + if (selectedDocumentId === document.id) { + setSelectedDocumentJobs(await listDocumentJobs(document.id)); + } setStatus({ tone: "success", message: "Ingestion retry queued." }); } catch (error) { setStatus({ tone: "error", message: resolveWorkspaceError(error) }); @@ -176,6 +194,21 @@ export function Workspace() { } } + async function handleSelectDocument(document: DocumentSummary) { + setSelectedDocumentId(document.id); + setSelectedDocumentJobs([]); + setJobHistoryError(null); + setIsLoadingJobs(true); + + try { + setSelectedDocumentJobs(await listDocumentJobs(document.id)); + } catch (error) { + setJobHistoryError(resolveWorkspaceError(error)); + } finally { + setIsLoadingJobs(false); + } + } + async function handleLogout() { await logout(); router.replace("/login"); @@ -275,12 +308,15 @@ export function Workspace() {

Documents

{documents.length > 0 ? documents.map((document) => ( -
+
{document.title} {document.content_type} {document.status} v{document.current_version}
+ {document.status === "failed" ? (
)}
+ {selectedDocument ? ( +
+
+
+

Job history

+

{selectedDocument.title}

+
+ {selectedDocument.status} +
+ {jobHistoryError ? ( +
{jobHistoryError}
+ ) : null} + {isLoadingJobs ? ( +
Loading ingestion jobs...
+ ) : selectedDocumentJobs.length > 0 ? ( +
+ {selectedDocumentJobs.map((job) => ( +
+
+ {job.status} + {job.id} +
+ attempts {job.attempts} + {formatDate(job.finished_at ?? job.started_at ?? job.queued_at)} + {job.error_message ?

{job.error_code ? `${job.error_code}: ` : ""}{job.error_message}

: null} +
+ ))} +
+ ) : ( +
No ingestion jobs found for this document.
+ )} +
+ ) : null}
{usageMetrics.map((metric) => ( @@ -336,6 +405,13 @@ function slugify(value: string) { return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "") || "document"; } +function formatDate(value: string) { + return new Intl.DateTimeFormat("en", { + dateStyle: "medium", + timeStyle: "short" + }).format(new Date(value)); +} + function resolveWorkspaceError(error: unknown) { if (error instanceof ApiError) { return error.message;