polling and sort by doc
Browse files- frontend/src/hooks/usePolling.js +45 -0
- frontend/src/pages/Activity.jsx +79 -6
- frontend/src/pages/Dashboard.jsx +14 -4
- frontend/src/pages/Knowledge.jsx +119 -93
- frontend/src/pages/Workflows.jsx +8 -0
frontend/src/hooks/usePolling.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useEffect, useRef } from "react";
|
| 2 |
+
|
| 3 |
+
/**
|
| 4 |
+
* usePolling — self-rescheduling setTimeout-based polling.
|
| 5 |
+
*
|
| 6 |
+
* Calls `callback` every `interval` ms while `active` is true.
|
| 7 |
+
* Stops automatically when `active` becomes false.
|
| 8 |
+
* Cleans up on unmount.
|
| 9 |
+
*
|
| 10 |
+
* Uses setTimeout (not setInterval) so each poll waits for the
|
| 11 |
+
* previous one to finish before scheduling the next — identical
|
| 12 |
+
* to the pattern used in Documents.jsx.
|
| 13 |
+
*
|
| 14 |
+
* @param {() => Promise<void>} callback Async function to call each tick.
|
| 15 |
+
* @param {number} interval Milliseconds between polls.
|
| 16 |
+
* @param {boolean} active Poll only while this is true.
|
| 17 |
+
*/
|
| 18 |
+
export function usePolling(callback, interval, active) {
|
| 19 |
+
const timerRef = useRef(null);
|
| 20 |
+
const callbackRef = useRef(callback);
|
| 21 |
+
|
| 22 |
+
// Keep callbackRef current without restarting the effect.
|
| 23 |
+
useEffect(() => {
|
| 24 |
+
callbackRef.current = callback;
|
| 25 |
+
}, [callback]);
|
| 26 |
+
|
| 27 |
+
useEffect(() => {
|
| 28 |
+
if (!active) {
|
| 29 |
+
clearTimeout(timerRef.current);
|
| 30 |
+
timerRef.current = null;
|
| 31 |
+
return;
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
timerRef.current = setTimeout(async () => {
|
| 35 |
+
await callbackRef.current();
|
| 36 |
+
}, interval);
|
| 37 |
+
|
| 38 |
+
return () => {
|
| 39 |
+
clearTimeout(timerRef.current);
|
| 40 |
+
timerRef.current = null;
|
| 41 |
+
};
|
| 42 |
+
// Re-run whenever active or the data that drives active changes.
|
| 43 |
+
// interval is stable so including it has no cost.
|
| 44 |
+
}, [active, interval]);
|
| 45 |
+
}
|
frontend/src/pages/Activity.jsx
CHANGED
|
@@ -1,7 +1,9 @@
|
|
| 1 |
import { useCallback, useEffect, useState } from "react";
|
| 2 |
import { useWorkspace } from "../hooks/useWorkspace";
|
|
|
|
| 3 |
import { listActivity } from "../api/activity";
|
| 4 |
-
import {
|
|
|
|
| 5 |
import { GLOSSARY } from "../utils/labels";
|
| 6 |
import "./Activity.css";
|
| 7 |
|
|
@@ -29,6 +31,9 @@ const TYPE_TONE = {
|
|
| 29 |
commit_created: "success",
|
| 30 |
};
|
| 31 |
|
|
|
|
|
|
|
|
|
|
| 32 |
export default function Activity() {
|
| 33 |
const { workspace, loading: wsLoading } = useWorkspace();
|
| 34 |
const [events, setEvents] = useState([]);
|
|
@@ -36,7 +41,11 @@ export default function Activity() {
|
|
| 36 |
const [loadingMore, setLoadingMore] = useState(false);
|
| 37 |
const [nextCursor, setNextCursor] = useState(null);
|
| 38 |
const [hasMore, setHasMore] = useState(false);
|
|
|
|
|
|
|
|
|
|
| 39 |
|
|
|
|
| 40 |
const load = useCallback(async () => {
|
| 41 |
if (!workspace) return;
|
| 42 |
setLoading(true);
|
|
@@ -53,6 +62,25 @@ export default function Activity() {
|
|
| 53 |
}
|
| 54 |
}, [workspace]);
|
| 55 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
const loadOlder = useCallback(async () => {
|
| 57 |
if (!workspace || !nextCursor || loadingMore) return;
|
| 58 |
setLoadingMore(true);
|
|
@@ -69,11 +97,37 @@ export default function Activity() {
|
|
| 69 |
}
|
| 70 |
}, [workspace, nextCursor, loadingMore]);
|
| 71 |
|
|
|
|
| 72 |
useEffect(() => { load(); }, [load]);
|
| 73 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
if (wsLoading) return <LoadingState label="Loading workspace…" />;
|
| 75 |
if (!workspace) return <EmptyState title="No workspace" description="Create a workspace to get started." />;
|
| 76 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
return (
|
| 78 |
<div className="dw-page">
|
| 79 |
<header className="dw-page__header">
|
|
@@ -83,17 +137,35 @@ export default function Activity() {
|
|
| 83 |
|
| 84 |
<Glossary terms={GLOSSARY.activity} />
|
| 85 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
{loading ? (
|
| 87 |
<LoadingState label="Loading activity…" />
|
| 88 |
-
) :
|
| 89 |
<EmptyState
|
| 90 |
-
title="No activity yet"
|
| 91 |
-
description=
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
/>
|
| 93 |
) : (
|
| 94 |
<>
|
| 95 |
<div className="dw-activity__list">
|
| 96 |
-
{
|
| 97 |
<div key={event.id} className="dw-activity__item">
|
| 98 |
<Badge tone={TYPE_TONE[event.type] || "default"}>
|
| 99 |
{TYPE_LABELS[event.type] || event.type}
|
|
@@ -108,7 +180,8 @@ export default function Activity() {
|
|
| 108 |
))}
|
| 109 |
</div>
|
| 110 |
|
| 111 |
-
{
|
|
|
|
| 112 |
<div className="dw-activity__load-more">
|
| 113 |
<button
|
| 114 |
className="dw-activity__load-more-btn"
|
|
|
|
| 1 |
import { useCallback, useEffect, useState } from "react";
|
| 2 |
import { useWorkspace } from "../hooks/useWorkspace";
|
| 3 |
+
import { usePolling } from "../hooks/usePolling";
|
| 4 |
import { listActivity } from "../api/activity";
|
| 5 |
+
import { listDocuments } from "../api/documents";
|
| 6 |
+
import { Badge, LoadingState, EmptyState, Glossary } from "../components/ui";
|
| 7 |
import { GLOSSARY } from "../utils/labels";
|
| 8 |
import "./Activity.css";
|
| 9 |
|
|
|
|
| 31 |
commit_created: "success",
|
| 32 |
};
|
| 33 |
|
| 34 |
+
// How often to check for new events while the page is open.
|
| 35 |
+
const REFRESH_INTERVAL = 15000;
|
| 36 |
+
|
| 37 |
export default function Activity() {
|
| 38 |
const { workspace, loading: wsLoading } = useWorkspace();
|
| 39 |
const [events, setEvents] = useState([]);
|
|
|
|
| 41 |
const [loadingMore, setLoadingMore] = useState(false);
|
| 42 |
const [nextCursor, setNextCursor] = useState(null);
|
| 43 |
const [hasMore, setHasMore] = useState(false);
|
| 44 |
+
// Document filter: "" = All
|
| 45 |
+
const [docFilter, setDocFilter] = useState("");
|
| 46 |
+
const [documents, setDocuments] = useState([]);
|
| 47 |
|
| 48 |
+
// Initial full load — replaces whatever is in state.
|
| 49 |
const load = useCallback(async () => {
|
| 50 |
if (!workspace) return;
|
| 51 |
setLoading(true);
|
|
|
|
| 62 |
}
|
| 63 |
}, [workspace]);
|
| 64 |
|
| 65 |
+
// Background refresh — prepends only events the list doesn't have yet.
|
| 66 |
+
const refresh = useCallback(async () => {
|
| 67 |
+
if (!workspace) return;
|
| 68 |
+
try {
|
| 69 |
+
const response = await listActivity(workspace.id, 50);
|
| 70 |
+
const fresh = response?.events ?? (Array.isArray(response) ? response : []);
|
| 71 |
+
if (fresh.length === 0) return;
|
| 72 |
+
setEvents((prev) => {
|
| 73 |
+
const existingIds = new Set(prev.map((e) => e.id));
|
| 74 |
+
const newOnes = fresh.filter((e) => !existingIds.has(e.id));
|
| 75 |
+
if (newOnes.length === 0) return prev;
|
| 76 |
+
return [...newOnes, ...prev];
|
| 77 |
+
});
|
| 78 |
+
} catch {
|
| 79 |
+
// silently handle
|
| 80 |
+
}
|
| 81 |
+
}, [workspace]);
|
| 82 |
+
|
| 83 |
+
// Load older events (manual pagination, appended to the bottom).
|
| 84 |
const loadOlder = useCallback(async () => {
|
| 85 |
if (!workspace || !nextCursor || loadingMore) return;
|
| 86 |
setLoadingMore(true);
|
|
|
|
| 97 |
}
|
| 98 |
}, [workspace, nextCursor, loadingMore]);
|
| 99 |
|
| 100 |
+
// Initial load.
|
| 101 |
useEffect(() => { load(); }, [load]);
|
| 102 |
|
| 103 |
+
// Load document list once for the dropdown.
|
| 104 |
+
useEffect(() => {
|
| 105 |
+
if (!workspace) return;
|
| 106 |
+
listDocuments(workspace.id)
|
| 107 |
+
.then((result) => { if (Array.isArray(result)) setDocuments(result); })
|
| 108 |
+
.catch(() => {});
|
| 109 |
+
}, [workspace]);
|
| 110 |
+
|
| 111 |
+
// Poll every 15 s to prepend new events while the page is open.
|
| 112 |
+
usePolling(refresh, REFRESH_INTERVAL, !!workspace);
|
| 113 |
+
|
| 114 |
+
// Apply document filter client-side.
|
| 115 |
+
// Events carry metadata.filename and/or metadata.document_id.
|
| 116 |
+
const visibleEvents = docFilter
|
| 117 |
+
? events.filter((e) => {
|
| 118 |
+
const meta = e.metadata || {};
|
| 119 |
+
return meta.filename === docFilter;
|
| 120 |
+
})
|
| 121 |
+
: events;
|
| 122 |
+
|
| 123 |
if (wsLoading) return <LoadingState label="Loading workspace…" />;
|
| 124 |
if (!workspace) return <EmptyState title="No workspace" description="Create a workspace to get started." />;
|
| 125 |
|
| 126 |
+
// Build sorted unique filenames for the dropdown.
|
| 127 |
+
const docNames = Array.from(
|
| 128 |
+
new Set(documents.map((d) => d.filename).filter(Boolean))
|
| 129 |
+
).sort();
|
| 130 |
+
|
| 131 |
return (
|
| 132 |
<div className="dw-page">
|
| 133 |
<header className="dw-page__header">
|
|
|
|
| 137 |
|
| 138 |
<Glossary terms={GLOSSARY.activity} />
|
| 139 |
|
| 140 |
+
{/* Document filter dropdown */}
|
| 141 |
+
<div className="dw-knowledge__filters">
|
| 142 |
+
<select
|
| 143 |
+
className="dw-knowledge__filter-select"
|
| 144 |
+
value={docFilter}
|
| 145 |
+
onChange={(e) => setDocFilter(e.target.value)}
|
| 146 |
+
>
|
| 147 |
+
<option value="">All documents</option>
|
| 148 |
+
{docNames.map((name) => (
|
| 149 |
+
<option key={name} value={name}>{name}</option>
|
| 150 |
+
))}
|
| 151 |
+
</select>
|
| 152 |
+
</div>
|
| 153 |
+
|
| 154 |
{loading ? (
|
| 155 |
<LoadingState label="Loading activity…" />
|
| 156 |
+
) : visibleEvents.length === 0 ? (
|
| 157 |
<EmptyState
|
| 158 |
+
title={docFilter ? `No activity for "${docFilter}"` : "No activity yet"}
|
| 159 |
+
description={
|
| 160 |
+
docFilter
|
| 161 |
+
? "Try selecting a different document or All documents."
|
| 162 |
+
: "Upload a document or process a workflow to see activity here."
|
| 163 |
+
}
|
| 164 |
/>
|
| 165 |
) : (
|
| 166 |
<>
|
| 167 |
<div className="dw-activity__list">
|
| 168 |
+
{visibleEvents.map((event) => (
|
| 169 |
<div key={event.id} className="dw-activity__item">
|
| 170 |
<Badge tone={TYPE_TONE[event.type] || "default"}>
|
| 171 |
{TYPE_LABELS[event.type] || event.type}
|
|
|
|
| 180 |
))}
|
| 181 |
</div>
|
| 182 |
|
| 183 |
+
{/* Only show "load older" when showing all events, not filtered */}
|
| 184 |
+
{!docFilter && hasMore && (
|
| 185 |
<div className="dw-activity__load-more">
|
| 186 |
<button
|
| 187 |
className="dw-activity__load-more-btn"
|
frontend/src/pages/Dashboard.jsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
import { useCallback, useEffect, useState } from "react";
|
| 2 |
import { useNavigate } from "react-router-dom";
|
| 3 |
import { useWorkspace } from "../hooks/useWorkspace";
|
|
|
|
| 4 |
import { getDashboardStats } from "../api/dashboard";
|
| 5 |
import { listDocuments } from "../api/documents";
|
| 6 |
import { listActivity } from "../api/activity";
|
|
@@ -28,6 +29,9 @@ const EVENT_ICONS = {
|
|
| 28 |
commit_created: "💾",
|
| 29 |
};
|
| 30 |
|
|
|
|
|
|
|
|
|
|
| 31 |
export default function Dashboard() {
|
| 32 |
const { workspace, loading: wsLoading } = useWorkspace();
|
| 33 |
const navigate = useNavigate();
|
|
@@ -58,6 +62,10 @@ export default function Dashboard() {
|
|
| 58 |
|
| 59 |
useEffect(() => { load(); }, [load]);
|
| 60 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
if (wsLoading || loading) return <LoadingState label="Loading dashboard..." />;
|
| 62 |
if (!workspace) return <EmptyState title="No workspace" description="Create a workspace to get started." />;
|
| 63 |
|
|
@@ -109,10 +117,12 @@ export default function Dashboard() {
|
|
| 109 |
title="Recent Documents"
|
| 110 |
action={
|
| 111 |
<span style={{ display: "flex", alignItems: "center", gap: "var(--space-3)" }}>
|
| 112 |
-
|
| 113 |
-
<span style={{
|
| 114 |
-
|
| 115 |
-
|
|
|
|
|
|
|
| 116 |
<Button size="sm" variant="ghost" onClick={() => navigate("/documents")}>View all</Button>
|
| 117 |
</span>
|
| 118 |
}
|
|
|
|
| 1 |
import { useCallback, useEffect, useState } from "react";
|
| 2 |
import { useNavigate } from "react-router-dom";
|
| 3 |
import { useWorkspace } from "../hooks/useWorkspace";
|
| 4 |
+
import { usePolling } from "../hooks/usePolling";
|
| 5 |
import { getDashboardStats } from "../api/dashboard";
|
| 6 |
import { listDocuments } from "../api/documents";
|
| 7 |
import { listActivity } from "../api/activity";
|
|
|
|
| 29 |
commit_created: "💾",
|
| 30 |
};
|
| 31 |
|
| 32 |
+
const ACTIVE_STATUSES = new Set(["PENDING", "RUNNING"]);
|
| 33 |
+
const POLL_INTERVAL = 3000;
|
| 34 |
+
|
| 35 |
export default function Dashboard() {
|
| 36 |
const { workspace, loading: wsLoading } = useWorkspace();
|
| 37 |
const navigate = useNavigate();
|
|
|
|
| 62 |
|
| 63 |
useEffect(() => { load(); }, [load]);
|
| 64 |
|
| 65 |
+
// Poll every 3 s while any of the recent docs is actively processing.
|
| 66 |
+
const hasActive = recentDocs.some((d) => ACTIVE_STATUSES.has(d.workflow_status));
|
| 67 |
+
usePolling(load, POLL_INTERVAL, hasActive);
|
| 68 |
+
|
| 69 |
if (wsLoading || loading) return <LoadingState label="Loading dashboard..." />;
|
| 70 |
if (!workspace) return <EmptyState title="No workspace" description="Create a workspace to get started." />;
|
| 71 |
|
|
|
|
| 117 |
title="Recent Documents"
|
| 118 |
action={
|
| 119 |
<span style={{ display: "flex", alignItems: "center", gap: "var(--space-3)" }}>
|
| 120 |
+
{hasActive && (
|
| 121 |
+
<span style={{ fontSize: "var(--text-xs)", color: "var(--color-success)", display: "flex", alignItems: "center", gap: "4px" }}>
|
| 122 |
+
<span style={{ width: "6px", height: "6px", borderRadius: "50%", background: "var(--color-success)", display: "inline-block" }} />
|
| 123 |
+
Live
|
| 124 |
+
</span>
|
| 125 |
+
)}
|
| 126 |
<Button size="sm" variant="ghost" onClick={() => navigate("/documents")}>View all</Button>
|
| 127 |
</span>
|
| 128 |
}
|
frontend/src/pages/Knowledge.jsx
CHANGED
|
@@ -1,7 +1,9 @@
|
|
| 1 |
import { useCallback, useEffect, useState } from "react";
|
| 2 |
import { useNavigate } from "react-router-dom";
|
| 3 |
import { useWorkspace } from "../hooks/useWorkspace";
|
|
|
|
| 4 |
import { listKnowledge } from "../api/knowledge";
|
|
|
|
| 5 |
import { restoreProposal } from "../api/proposals";
|
| 6 |
import {
|
| 7 |
Card,
|
|
@@ -59,6 +61,9 @@ const STATUSES = [
|
|
| 59 |
"REJECTED",
|
| 60 |
];
|
| 61 |
|
|
|
|
|
|
|
|
|
|
| 62 |
function ConfidenceBar({ confidence }) {
|
| 63 |
if (confidence == null) return null;
|
| 64 |
|
|
@@ -93,19 +98,14 @@ function EvidenceBlock({ evidence }) {
|
|
| 93 |
return (
|
| 94 |
<div className="dw-knowledge__evidence">
|
| 95 |
{evidence.map((ev, i) => (
|
| 96 |
-
<div
|
| 97 |
-
key={i}
|
| 98 |
-
className="dw-knowledge__evidence-item"
|
| 99 |
-
>
|
| 100 |
<p className="dw-knowledge__evidence-quote">
|
| 101 |
"{ev.quote}"
|
| 102 |
</p>
|
| 103 |
-
|
| 104 |
<div className="dw-knowledge__evidence-meta">
|
| 105 |
{ev.page_number && (
|
| 106 |
<span>Page {ev.page_number}</span>
|
| 107 |
)}
|
| 108 |
-
|
| 109 |
{ev.section && (
|
| 110 |
<span>{ev.section}</span>
|
| 111 |
)}
|
|
@@ -124,6 +124,18 @@ export default function Knowledge() {
|
|
| 124 |
const [loading, setLoading] = useState(true);
|
| 125 |
const [typeFilter, setTypeFilter] = useState("");
|
| 126 |
const [statusFilter, setStatusFilter] = useState("");
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 127 |
|
| 128 |
const load = useCallback(async () => {
|
| 129 |
if (!workspace) return;
|
|
@@ -162,9 +174,18 @@ export default function Knowledge() {
|
|
| 162 |
}
|
| 163 |
}, [workspace, typeFilter, statusFilter]);
|
| 164 |
|
| 165 |
-
useEffect(() => {
|
| 166 |
-
|
| 167 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 168 |
|
| 169 |
if (wsLoading) {
|
| 170 |
return <LoadingState label="Loading workspace…" />;
|
|
@@ -179,6 +200,11 @@ export default function Knowledge() {
|
|
| 179 |
);
|
| 180 |
}
|
| 181 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 182 |
return (
|
| 183 |
<div className="dw-page">
|
| 184 |
<header className="dw-page__header">
|
|
@@ -192,13 +218,27 @@ export default function Knowledge() {
|
|
| 192 |
<Glossary terms={GLOSSARY.knowledge} />
|
| 193 |
|
| 194 |
<div className="dw-knowledge__filters">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 195 |
<select
|
| 196 |
className="dw-knowledge__filter-select"
|
| 197 |
value={typeFilter}
|
| 198 |
onChange={(e) => setTypeFilter(e.target.value)}
|
| 199 |
>
|
| 200 |
<option value="">All types</option>
|
| 201 |
-
|
| 202 |
{TYPES.filter(Boolean).map((type) => (
|
| 203 |
<option key={type} value={type}>
|
| 204 |
{KNOWLEDGE_TYPE_LABELS[type] || type}
|
|
@@ -206,13 +246,13 @@ export default function Knowledge() {
|
|
| 206 |
))}
|
| 207 |
</select>
|
| 208 |
|
|
|
|
| 209 |
<select
|
| 210 |
className="dw-knowledge__filter-select"
|
| 211 |
value={statusFilter}
|
| 212 |
onChange={(e) => setStatusFilter(e.target.value)}
|
| 213 |
>
|
| 214 |
<option value="">All statuses</option>
|
| 215 |
-
|
| 216 |
{STATUSES.filter(Boolean).map((status) => (
|
| 217 |
<option key={status} value={status}>
|
| 218 |
{KNOWLEDGE_STATUS_LABELS[status] || status}
|
|
@@ -223,15 +263,22 @@ export default function Knowledge() {
|
|
| 223 |
|
| 224 |
{loading ? (
|
| 225 |
<LoadingState label="Loading knowledge items…" />
|
| 226 |
-
) :
|
| 227 |
<EmptyState
|
| 228 |
-
title=
|
| 229 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 230 |
/>
|
| 231 |
) : (
|
| 232 |
<div className="dw-knowledge__list">
|
| 233 |
-
{
|
| 234 |
-
// Derive display status from proposal state
|
| 235 |
const displayStatus =
|
| 236 |
item.proposal?.status === "ARCHIVED"
|
| 237 |
? "ARCHIVED"
|
|
@@ -239,92 +286,71 @@ export default function Knowledge() {
|
|
| 239 |
const isArchived = item.proposal?.status === "ARCHIVED";
|
| 240 |
|
| 241 |
return (
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
{
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
tone={
|
| 263 |
-
STATUS_TONE[displayStatus] ||
|
| 264 |
-
"default"
|
| 265 |
-
}
|
| 266 |
-
>
|
| 267 |
-
{KNOWLEDGE_STATUS_LABELS[displayStatus] ||
|
| 268 |
-
displayStatus}
|
| 269 |
-
</Badge>
|
| 270 |
-
|
| 271 |
-
<ConfidenceBar
|
| 272 |
-
confidence={item.confidence}
|
| 273 |
-
/>
|
| 274 |
-
</div>
|
| 275 |
-
|
| 276 |
-
<p className="dw-knowledge__item-title">
|
| 277 |
-
{item.title}
|
| 278 |
-
</p>
|
| 279 |
-
|
| 280 |
-
<p className="dw-knowledge__item-value">
|
| 281 |
-
{item.value}
|
| 282 |
-
</p>
|
| 283 |
-
|
| 284 |
-
{item.summary && (
|
| 285 |
-
<p className="dw-knowledge__item-summary">
|
| 286 |
-
{item.summary}
|
| 287 |
</p>
|
| 288 |
-
)}
|
| 289 |
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
|
|
|
| 297 |
)}
|
| 298 |
|
| 299 |
-
{item.
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
<Button
|
| 311 |
-
size="sm"
|
| 312 |
-
variant="warning"
|
| 313 |
-
onClick={(e) => {
|
| 314 |
-
e.stopPropagation();
|
| 315 |
-
handleRestore(item.proposal.id, item.id);
|
| 316 |
-
}}
|
| 317 |
-
>
|
| 318 |
-
Restore to review
|
| 319 |
-
</Button>
|
| 320 |
</div>
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 324 |
);
|
| 325 |
})}
|
| 326 |
</div>
|
| 327 |
)}
|
| 328 |
</div>
|
| 329 |
);
|
| 330 |
-
}
|
|
|
|
| 1 |
import { useCallback, useEffect, useState } from "react";
|
| 2 |
import { useNavigate } from "react-router-dom";
|
| 3 |
import { useWorkspace } from "../hooks/useWorkspace";
|
| 4 |
+
import { usePolling } from "../hooks/usePolling";
|
| 5 |
import { listKnowledge } from "../api/knowledge";
|
| 6 |
+
import { listDocuments } from "../api/documents";
|
| 7 |
import { restoreProposal } from "../api/proposals";
|
| 8 |
import {
|
| 9 |
Card,
|
|
|
|
| 61 |
"REJECTED",
|
| 62 |
];
|
| 63 |
|
| 64 |
+
// Poll every 3 s while any item is still PENDING (being processed).
|
| 65 |
+
const POLL_INTERVAL = 3000;
|
| 66 |
+
|
| 67 |
function ConfidenceBar({ confidence }) {
|
| 68 |
if (confidence == null) return null;
|
| 69 |
|
|
|
|
| 98 |
return (
|
| 99 |
<div className="dw-knowledge__evidence">
|
| 100 |
{evidence.map((ev, i) => (
|
| 101 |
+
<div key={i} className="dw-knowledge__evidence-item">
|
|
|
|
|
|
|
|
|
|
| 102 |
<p className="dw-knowledge__evidence-quote">
|
| 103 |
"{ev.quote}"
|
| 104 |
</p>
|
|
|
|
| 105 |
<div className="dw-knowledge__evidence-meta">
|
| 106 |
{ev.page_number && (
|
| 107 |
<span>Page {ev.page_number}</span>
|
| 108 |
)}
|
|
|
|
| 109 |
{ev.section && (
|
| 110 |
<span>{ev.section}</span>
|
| 111 |
)}
|
|
|
|
| 124 |
const [loading, setLoading] = useState(true);
|
| 125 |
const [typeFilter, setTypeFilter] = useState("");
|
| 126 |
const [statusFilter, setStatusFilter] = useState("");
|
| 127 |
+
// Document dropdown: "" means All
|
| 128 |
+
const [docFilter, setDocFilter] = useState("");
|
| 129 |
+
// List of uploaded documents for the dropdown
|
| 130 |
+
const [documents, setDocuments] = useState([]);
|
| 131 |
+
|
| 132 |
+
// Load the document list once (for the dropdown).
|
| 133 |
+
useEffect(() => {
|
| 134 |
+
if (!workspace) return;
|
| 135 |
+
listDocuments(workspace.id)
|
| 136 |
+
.then((result) => { if (Array.isArray(result)) setDocuments(result); })
|
| 137 |
+
.catch(() => {});
|
| 138 |
+
}, [workspace]);
|
| 139 |
|
| 140 |
const load = useCallback(async () => {
|
| 141 |
if (!workspace) return;
|
|
|
|
| 174 |
}
|
| 175 |
}, [workspace, typeFilter, statusFilter]);
|
| 176 |
|
| 177 |
+
useEffect(() => { load(); }, [load]);
|
| 178 |
+
|
| 179 |
+
// Poll every 3 s while any knowledge item is still PENDING —
|
| 180 |
+
// new items appear automatically as documents finish processing.
|
| 181 |
+
const hasActive = items.some((i) => i.status === "PENDING");
|
| 182 |
+
usePolling(load, POLL_INTERVAL, hasActive);
|
| 183 |
+
|
| 184 |
+
// Apply document filter client-side (items already carry filename +
|
| 185 |
+
// document_version_id from the API response).
|
| 186 |
+
const visibleItems = docFilter
|
| 187 |
+
? items.filter((i) => i.filename === docFilter)
|
| 188 |
+
: items;
|
| 189 |
|
| 190 |
if (wsLoading) {
|
| 191 |
return <LoadingState label="Loading workspace…" />;
|
|
|
|
| 200 |
);
|
| 201 |
}
|
| 202 |
|
| 203 |
+
// Build sorted unique filenames for the dropdown.
|
| 204 |
+
const docNames = Array.from(
|
| 205 |
+
new Set(documents.map((d) => d.filename).filter(Boolean))
|
| 206 |
+
).sort();
|
| 207 |
+
|
| 208 |
return (
|
| 209 |
<div className="dw-page">
|
| 210 |
<header className="dw-page__header">
|
|
|
|
| 218 |
<Glossary terms={GLOSSARY.knowledge} />
|
| 219 |
|
| 220 |
<div className="dw-knowledge__filters">
|
| 221 |
+
{/* Document filter */}
|
| 222 |
+
<select
|
| 223 |
+
className="dw-knowledge__filter-select"
|
| 224 |
+
value={docFilter}
|
| 225 |
+
onChange={(e) => setDocFilter(e.target.value)}
|
| 226 |
+
>
|
| 227 |
+
<option value="">All documents</option>
|
| 228 |
+
{docNames.map((name) => (
|
| 229 |
+
<option key={name} value={name}>
|
| 230 |
+
{name}
|
| 231 |
+
</option>
|
| 232 |
+
))}
|
| 233 |
+
</select>
|
| 234 |
+
|
| 235 |
+
{/* Type filter */}
|
| 236 |
<select
|
| 237 |
className="dw-knowledge__filter-select"
|
| 238 |
value={typeFilter}
|
| 239 |
onChange={(e) => setTypeFilter(e.target.value)}
|
| 240 |
>
|
| 241 |
<option value="">All types</option>
|
|
|
|
| 242 |
{TYPES.filter(Boolean).map((type) => (
|
| 243 |
<option key={type} value={type}>
|
| 244 |
{KNOWLEDGE_TYPE_LABELS[type] || type}
|
|
|
|
| 246 |
))}
|
| 247 |
</select>
|
| 248 |
|
| 249 |
+
{/* Status filter */}
|
| 250 |
<select
|
| 251 |
className="dw-knowledge__filter-select"
|
| 252 |
value={statusFilter}
|
| 253 |
onChange={(e) => setStatusFilter(e.target.value)}
|
| 254 |
>
|
| 255 |
<option value="">All statuses</option>
|
|
|
|
| 256 |
{STATUSES.filter(Boolean).map((status) => (
|
| 257 |
<option key={status} value={status}>
|
| 258 |
{KNOWLEDGE_STATUS_LABELS[status] || status}
|
|
|
|
| 263 |
|
| 264 |
{loading ? (
|
| 265 |
<LoadingState label="Loading knowledge items…" />
|
| 266 |
+
) : visibleItems.length === 0 ? (
|
| 267 |
<EmptyState
|
| 268 |
+
title={
|
| 269 |
+
docFilter
|
| 270 |
+
? `No knowledge items for "${docFilter}"`
|
| 271 |
+
: "No knowledge has been committed yet"
|
| 272 |
+
}
|
| 273 |
+
description={
|
| 274 |
+
docFilter
|
| 275 |
+
? "Try selecting a different document or All documents."
|
| 276 |
+
: "Upload and process documents to begin building the knowledge register."
|
| 277 |
+
}
|
| 278 |
/>
|
| 279 |
) : (
|
| 280 |
<div className="dw-knowledge__list">
|
| 281 |
+
{visibleItems.map((item) => {
|
|
|
|
| 282 |
const displayStatus =
|
| 283 |
item.proposal?.status === "ARCHIVED"
|
| 284 |
? "ARCHIVED"
|
|
|
|
| 286 |
const isArchived = item.proposal?.status === "ARCHIVED";
|
| 287 |
|
| 288 |
return (
|
| 289 |
+
<Card
|
| 290 |
+
key={item.id}
|
| 291 |
+
interactive
|
| 292 |
+
onClick={() => navigate(`/knowledge/${item.id}`)}
|
| 293 |
+
>
|
| 294 |
+
<CardBody>
|
| 295 |
+
<div className="dw-knowledge__item-header">
|
| 296 |
+
<Badge tone={TYPE_TONE[item.type] || "default"}>
|
| 297 |
+
{KNOWLEDGE_TYPE_LABELS[item.type] || item.type}
|
| 298 |
+
</Badge>
|
| 299 |
+
|
| 300 |
+
<Badge tone={STATUS_TONE[displayStatus] || "default"}>
|
| 301 |
+
{KNOWLEDGE_STATUS_LABELS[displayStatus] || displayStatus}
|
| 302 |
+
</Badge>
|
| 303 |
+
|
| 304 |
+
<ConfidenceBar confidence={item.confidence} />
|
| 305 |
+
</div>
|
| 306 |
+
|
| 307 |
+
<p className="dw-knowledge__item-title">
|
| 308 |
+
{item.title}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 309 |
</p>
|
|
|
|
| 310 |
|
| 311 |
+
<p className="dw-knowledge__item-value">
|
| 312 |
+
{item.value}
|
| 313 |
+
</p>
|
| 314 |
|
| 315 |
+
{item.summary && (
|
| 316 |
+
<p className="dw-knowledge__item-summary">
|
| 317 |
+
{item.summary}
|
| 318 |
+
</p>
|
| 319 |
)}
|
| 320 |
|
| 321 |
+
<EvidenceBlock evidence={item.evidence} />
|
| 322 |
+
|
| 323 |
+
<div className="dw-knowledge__item-meta">
|
| 324 |
+
{item.filename && (
|
| 325 |
+
<span>{item.filename}</span>
|
| 326 |
+
)}
|
| 327 |
+
{item.created_at && (
|
| 328 |
+
<span>
|
| 329 |
+
{new Date(item.created_at).toLocaleDateString()}
|
| 330 |
+
</span>
|
| 331 |
+
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 332 |
</div>
|
| 333 |
+
|
| 334 |
+
{isArchived && item.proposal?.id && (
|
| 335 |
+
<div className="dw-knowledge__item-actions">
|
| 336 |
+
<Button
|
| 337 |
+
size="sm"
|
| 338 |
+
variant="warning"
|
| 339 |
+
onClick={(e) => {
|
| 340 |
+
e.stopPropagation();
|
| 341 |
+
handleRestore(item.proposal.id, item.id);
|
| 342 |
+
}}
|
| 343 |
+
>
|
| 344 |
+
Restore to review
|
| 345 |
+
</Button>
|
| 346 |
+
</div>
|
| 347 |
+
)}
|
| 348 |
+
</CardBody>
|
| 349 |
+
</Card>
|
| 350 |
);
|
| 351 |
})}
|
| 352 |
</div>
|
| 353 |
)}
|
| 354 |
</div>
|
| 355 |
);
|
| 356 |
+
}
|
frontend/src/pages/Workflows.jsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
import { useCallback, useEffect, useState } from "react";
|
| 2 |
import { useNavigate, useSearchParams } from "react-router-dom";
|
| 3 |
import { useWorkspace } from "../hooks/useWorkspace";
|
|
|
|
| 4 |
import { listWorkflows } from "../api/workflows";
|
| 5 |
import { retryDocument } from "../api/documents";
|
| 6 |
import { Button, Badge, LoadingState, EmptyState, DataTable } from "../components/ui";
|
|
@@ -12,6 +13,9 @@ const STATUS_TONE = {
|
|
| 12 |
COMPLETED: "success", FAILED: "danger", CANCELLED: "info",
|
| 13 |
};
|
| 14 |
|
|
|
|
|
|
|
|
|
|
| 15 |
function duration(started, completed) {
|
| 16 |
if (!started || !completed) return "—";
|
| 17 |
const ms = new Date(completed) - new Date(started);
|
|
@@ -39,6 +43,10 @@ export default function Workflows() {
|
|
| 39 |
|
| 40 |
useEffect(() => { load(); }, [load]);
|
| 41 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
if (wsLoading) return <LoadingState label="Loading..." />;
|
| 43 |
if (!workspace) return <EmptyState title="No workspace" />;
|
| 44 |
|
|
|
|
| 1 |
import { useCallback, useEffect, useState } from "react";
|
| 2 |
import { useNavigate, useSearchParams } from "react-router-dom";
|
| 3 |
import { useWorkspace } from "../hooks/useWorkspace";
|
| 4 |
+
import { usePolling } from "../hooks/usePolling";
|
| 5 |
import { listWorkflows } from "../api/workflows";
|
| 6 |
import { retryDocument } from "../api/documents";
|
| 7 |
import { Button, Badge, LoadingState, EmptyState, DataTable } from "../components/ui";
|
|
|
|
| 13 |
COMPLETED: "success", FAILED: "danger", CANCELLED: "info",
|
| 14 |
};
|
| 15 |
|
| 16 |
+
const ACTIVE_STATUSES = new Set(["PENDING", "RUNNING"]);
|
| 17 |
+
const POLL_INTERVAL = 3000;
|
| 18 |
+
|
| 19 |
function duration(started, completed) {
|
| 20 |
if (!started || !completed) return "—";
|
| 21 |
const ms = new Date(completed) - new Date(started);
|
|
|
|
| 43 |
|
| 44 |
useEffect(() => { load(); }, [load]);
|
| 45 |
|
| 46 |
+
// Poll every 3 s while any workflow is PENDING or RUNNING.
|
| 47 |
+
const hasActive = workflows.some((w) => ACTIVE_STATUSES.has(w.status));
|
| 48 |
+
usePolling(load, POLL_INTERVAL, hasActive);
|
| 49 |
+
|
| 50 |
if (wsLoading) return <LoadingState label="Loading..." />;
|
| 51 |
if (!workspace) return <EmptyState title="No workspace" />;
|
| 52 |
|