feat(processing-folders): a processing folder opens as Inputs / Outputs / Processing

A mount with processing attached no longer opens onto a flat listing of its
originals — with the results landing in a separate directory the folder
looked untouched, and the one place named after the source showed exactly
the files that carry no categories. It now presents as a master folder of
three fixed sections: Inputs (the originals, read straight off the watched
directory, never changed), Outputs (the processed results, read off
whatever directory the record delivers to — today a subfolder, later
wherever an output picker points), and Processing (the runs executing right
now, polled live with each document's name and step cursor).

The sections are pure presentation: no stored folder backs them, the master
IS the existing mount record, and navigation rides a `section` URL query
the pathname sync already strips on any real folder change. The breadcrumb
grows a trailing section crumb, with the master's own crumb clearing the
section. Counts on the three cards come from the two directory listings and
the live runs poll.

PolicyRunView now carries the input document's display name (the trailing
segment of a path-shaped file identity) — a client showing live runs needs
something to call them before any output exists — and the processing-folder
hook exposes the record's output directory and an active-runs listing
through the same core/proprietary seam as the rest of its API.
This commit is contained in:
Reece
2026-08-17 17:41:05 +01:00
parent b7c9065a4e
commit ffa85c2a74
7 changed files with 545 additions and 18 deletions
@@ -21,7 +21,13 @@ public record PolicyRunView(
Boolean errorSubscribed,
List<ResultFile> outputs,
/** When the run was created, epoch millis, so a rediscovered run shows its real age. */
long createdAt) {
long createdAt,
/**
* The input document's display name, when the run's source recorded one — a client showing
* live runs needs something to call them before any output exists. Null for uploads and
* cross-node views, whose identity is not name-shaped.
*/
String fileName) {
public static PolicyRunView of(PolicyRun run) {
return new PolicyRunView(
@@ -34,7 +40,21 @@ public record PolicyRunView(
run.getErrorCode(),
run.getErrorSubscribed(),
run.getOutputs(),
run.getCreatedAt().toEpochMilli());
run.getCreatedAt().toEpochMilli(),
fileNameOf(run.getFileIdentity()));
}
/**
* The trailing path segment of a path-shaped file identity (a folder source's identity is the
* document's absolute path). Identities that aren't path-shaped pass through whole — for a
* storage source that is still a recognisable reference, and null stays null.
*/
private static String fileNameOf(String fileIdentity) {
if (fileIdentity == null || fileIdentity.isBlank()) {
return null;
}
int cut = Math.max(fileIdentity.lastIndexOf('/'), fileIdentity.lastIndexOf('\\'));
return cut < 0 ? fileIdentity : fileIdentity.substring(cut + 1);
}
/** Cross-node view from a shared job-store entry; step cursor is node-local so it reads 0. */
@@ -63,6 +83,7 @@ public record PolicyRunView(
null,
null,
outputs,
createdAt);
createdAt,
null);
}
}
@@ -4168,6 +4168,16 @@ start = "Process files in this folder…"
stop = "Stop processing this folder"
sweep = "Process files now"
[filesPage.processingSections]
inputs = "Inputs"
inputsHint = "Your originals — never changed"
outputs = "Outputs"
outputsHint = "Processed results"
processing = "Processing"
processingHint = "Being processed right now"
running = "Processing…"
runStep = "Step {{current}} of {{total}}"
[filesPage.search]
clear = "Clear filter"
label = "Filter files by name"
@@ -1,6 +1,6 @@
import React, { useCallback, useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import { Checkbox, Menu, Tooltip } from "@mantine/core";
import { Checkbox, Loader, Menu, Tooltip } from "@mantine/core";
import { Button } from "@app/ui/Button";
import { ActionIcon } from "@app/ui/ActionIcon";
import MoreVertIcon from "@mui/icons-material/MoreVert";
@@ -17,6 +17,8 @@ import CloudUploadIcon from "@mui/icons-material/CloudUpload";
import UploadFileIcon from "@mui/icons-material/UploadFile";
import CreateNewFolderIcon from "@mui/icons-material/CreateNewFolder";
import AutoModeIcon from "@mui/icons-material/AutoMode";
import MoveToInboxIcon from "@mui/icons-material/MoveToInbox";
import TaskAltIcon from "@mui/icons-material/TaskAlt";
import SearchIcon from "@mui/icons-material/Search";
import { FileId } from "@app/types/file";
@@ -96,14 +98,39 @@ function useFolderOriginBadge(folder: FolderRecord): {
export type FilesPageViewMode = "grid" | "list";
/**
* The fixed sections a processing folder presents in place of a flat listing:
* the untouched originals, the processed results, and what is running right
* now. Presentation only — none of these is a stored folder.
*/
export type ProcessingSectionId = "inputs" | "outputs" | "processing";
export interface ProcessingSectionEntry {
id: ProcessingSectionId;
/** Items inside (files listed, or runs in flight); null while still loading. */
count: number | null;
}
/** One in-flight run, shown as a row inside the Processing section. */
export interface ProcessingRunEntry {
runId: string;
fileName: string;
currentStep: number;
stepCount: number;
}
export interface FilesPageEntry {
kind: "folder" | "file" | "diskFile";
kind: "folder" | "file" | "diskFile" | "section" | "run";
folder?: FolderRecord;
/** Number of files inside this folder (folder entries only). */
folderFileCount?: number;
file?: StirlingFileStub;
/** A file read straight off a mounted directory (kind "diskFile"). */
disk?: DiskFileEntry;
/** A processing folder's section (kind "section"). */
section?: ProcessingSectionEntry;
/** A run in flight inside the Processing section (kind "run"). */
run?: ProcessingRunEntry;
/** Parent breadcrumb path for search results outside the current folder. */
parentPath?: string;
}
@@ -118,6 +145,8 @@ interface FileGridProps {
/** Replace the entire selection set. */
onSetSelection?: (ids: Set<FileId>) => void;
onOpenFolder: (id: FolderId) => void;
/** Open one of a processing folder's sections. */
onOpenSection?: (id: ProcessingSectionId) => void;
/** "Add to workspace". */
onOpenFile: (file: StirlingFileStub) => void;
/** Open a file listed straight from a mounted directory. */
@@ -431,6 +460,7 @@ function GridView({
activeWorkspaceFileIds,
onSelectFile,
onOpenFolder,
onOpenSection,
onOpenFile,
onOpenDiskFile,
onMoveFiles,
@@ -447,6 +477,18 @@ function GridView({
return (
<div className="files-page-grid" role="list">
{entries.map((entry) => {
if (entry.kind === "section" && entry.section) {
return (
<SectionCard
key={`section-${entry.section.id}`}
section={entry.section}
onOpen={() => onOpenSection?.(entry.section!.id)}
/>
);
}
if (entry.kind === "run" && entry.run) {
return <RunCard key={`run-${entry.run.runId}`} run={entry.run} />;
}
if (entry.kind === "folder" && entry.folder) {
return (
<FolderCard
@@ -821,6 +863,199 @@ function ProcessingMenuItems({
);
}
/** Section display names, shared with the breadcrumb trail. */
export const PROCESSING_SECTION_LABELS: Record<
ProcessingSectionId,
{ key: string; fallback: string }
> = {
inputs: { key: "filesPage.processingSections.inputs", fallback: "Inputs" },
outputs: {
key: "filesPage.processingSections.outputs",
fallback: "Outputs",
},
processing: {
key: "filesPage.processingSections.processing",
fallback: "Processing",
},
};
/**
* The look and copy of each processing-folder section. Fixed identities: the
* cards must read the same in every processing folder, so none of the folder
* appearance machinery applies here.
*/
const SECTION_META: Record<
ProcessingSectionId,
{
color: string;
Icon: typeof MoveToInboxIcon;
hintKey: string;
hintDefault: string;
}
> = {
inputs: {
color: "#3b82f6",
Icon: MoveToInboxIcon,
hintKey: "filesPage.processingSections.inputsHint",
hintDefault: "Your originals — never changed",
},
outputs: {
color: "#10b981",
Icon: TaskAltIcon,
hintKey: "filesPage.processingSections.outputsHint",
hintDefault: "Processed results",
},
processing: {
color: "#f59e0b",
Icon: AutoModeIcon,
hintKey: "filesPage.processingSections.processingHint",
hintDefault: "Being processed right now",
},
};
/** One of a processing folder's sections, presented as a folder-style card. */
function SectionCard({
section,
onOpen,
}: {
section: ProcessingSectionEntry;
onOpen: () => void;
}) {
const { t } = useTranslation();
const meta = SECTION_META[section.id];
return (
<div
role="listitem"
tabIndex={0}
className="files-page-card is-folder"
onDoubleClick={onOpen}
onClick={onOpen}
onKeyDown={(e) => {
if (e.key === "Enter") onOpen();
}}
>
<div
className="files-page-card-thumb"
style={{
background: `linear-gradient(135deg, color-mix(in srgb, ${meta.color} 18%, var(--c-surface)), color-mix(in srgb, ${meta.color} 6%, var(--c-surface)))`,
}}
>
<meta.Icon style={{ fontSize: "2.5rem", color: meta.color }} />
</div>
<div className="files-page-card-body">
<div className="files-page-card-name">
{t(
PROCESSING_SECTION_LABELS[section.id].key,
PROCESSING_SECTION_LABELS[section.id].fallback,
)}
</div>
<div className="files-page-card-meta">
{t(meta.hintKey, meta.hintDefault)}
{section.count !== null &&
` · ${t("filesPage.folderItems", "{{count}} items", { count: section.count })}`}
</div>
</div>
</div>
);
}
/** List-view counterpart of {@link SectionCard}. */
function SectionRow({
section,
onOpen,
}: {
section: ProcessingSectionEntry;
onOpen: () => void;
}) {
const { t } = useTranslation();
const meta = SECTION_META[section.id];
return (
<div
role="row"
tabIndex={0}
className="files-page-list-row is-folder"
onDoubleClick={onOpen}
onClick={onOpen}
onKeyDown={(e) => {
if (e.key === "Enter") onOpen();
}}
>
<span aria-hidden="true" />
<span className="files-page-list-name">
<meta.Icon fontSize="small" style={{ color: meta.color }} />
<span>
{t(
PROCESSING_SECTION_LABELS[section.id].key,
PROCESSING_SECTION_LABELS[section.id].fallback,
)}
</span>
</span>
<span role="gridcell">{t(meta.hintKey, meta.hintDefault)}</span>
<span role="gridcell">
{section.count === null
? "-"
: t("filesPage.folderItems", "{{count}} items", {
count: section.count,
})}
</span>
<span role="gridcell">-</span>
<span aria-hidden="true" />
</div>
);
}
/** A run in flight, shown inside the Processing section. Read-only. */
function RunCard({ run }: { run: ProcessingRunEntry }) {
const { t } = useTranslation();
return (
<div role="listitem" className="files-page-card">
<div className="files-page-card-thumb">
<Loader size="sm" />
</div>
<div className="files-page-card-body">
<div className="files-page-card-name" title={run.fileName}>
{run.fileName}
</div>
<div className="files-page-card-meta">
{run.stepCount > 0
? t("filesPage.processingSections.runStep", {
current: Math.min(run.currentStep + 1, run.stepCount),
total: run.stepCount,
defaultValue: "Step {{current}} of {{total}}",
})
: t("filesPage.processingSections.running", "Processing…")}
</div>
</div>
</div>
);
}
/** List-view counterpart of {@link RunCard}. */
function RunRow({ run }: { run: ProcessingRunEntry }) {
const { t } = useTranslation();
return (
<div role="row" className="files-page-list-row">
<span aria-hidden="true" />
<span className="files-page-list-name">
<Loader size="xs" />
<span title={run.fileName}>{run.fileName}</span>
</span>
<span role="gridcell">
{run.stepCount > 0
? t("filesPage.processingSections.runStep", {
current: Math.min(run.currentStep + 1, run.stepCount),
total: run.stepCount,
defaultValue: "Step {{current}} of {{total}}",
})
: t("filesPage.processingSections.running", "Processing…")}
</span>
<span role="gridcell">-</span>
<span role="gridcell">-</span>
<span aria-hidden="true" />
</div>
);
}
/** Shield badges for the policies that have run on a file. */
function PolicyBadges({ fileId }: { fileId: string }) {
const badges = usePolicyFileBadges().get(fileId) ?? [];
@@ -1088,6 +1323,7 @@ function ListView({
onSelectFile,
onSetSelection,
onOpenFolder,
onOpenSection,
onOpenFile,
onOpenDiskFile,
onMoveFiles,
@@ -1195,6 +1431,18 @@ function ListView({
<span aria-hidden="true" />
</div>
{entries.map((entry) => {
if (entry.kind === "section" && entry.section) {
return (
<SectionRow
key={`section-${entry.section.id}`}
section={entry.section}
onOpen={() => onOpenSection?.(entry.section!.id)}
/>
);
}
if (entry.kind === "run" && entry.run) {
return <RunRow key={`run-${entry.run.runId}`} run={entry.run} />;
}
if (entry.kind === "folder" && entry.folder) {
return (
<FolderRow
@@ -63,7 +63,16 @@ import { FileId } from "@app/types/file";
import { StirlingFileStub } from "@app/types/fileContext";
import { FolderId, ROOT_FOLDER_ID, folderKind } from "@app/types/folder";
import { FileGrid, FilesPageEntry } from "@app/components/filesPage/FileGrid";
import {
FileGrid,
FilesPageEntry,
PROCESSING_SECTION_LABELS,
ProcessingSectionId,
} from "@app/components/filesPage/FileGrid";
import {
useProcessingFolders,
type ProcessingRunInfo,
} from "@app/hooks/useProcessingFolders";
import SuperSearch from "@app/components/shared/superSearch/SuperSearch";
import { useEditorSearchScopes } from "@app/hooks/useSuperSearch";
import { FileDetailsPanel } from "@app/components/filesPage/FileDetailsPanel";
@@ -481,6 +490,91 @@ export default function FileManagerView() {
// make a failing listing re-trigger itself.
}, [currentLocalDirectory, setFolderError]);
// ─── processing-folder sections (Inputs / Outputs / Processing) ─────────
// A mount with processing attached presents as a master folder of three
// fixed sections instead of a flat listing: the untouched originals, the
// processed results (wherever the record says they land), and what is
// running right now. Pure presentation — no stored folder backs a section.
const processingApi = useProcessingFolders();
const currentProcessing = currentFolder
? processingApi.stateFor(currentFolder)
: undefined;
const outputDirectory = currentLocalDirectory
? currentProcessing?.outputDirectory
: undefined;
const rawSection = new URLSearchParams(location.search).get("section");
const processingSection: ProcessingSectionId | null =
outputDirectory &&
(rawSection === "inputs" ||
rawSection === "outputs" ||
rawSection === "processing")
? rawSection
: null;
const [outputEntries, setOutputEntries] = useState<DiskFileEntry[]>([]);
const [outputLoading, setOutputLoading] = useState(false);
useEffect(() => {
if (!outputDirectory || !canListDirectory) {
setOutputEntries([]);
setOutputLoading(false);
return;
}
let cancelled = false;
setOutputLoading(true);
listDirectory(outputDirectory)
.then((listed) => {
if (!cancelled) setOutputEntries(listed ?? []);
})
.catch(() => {
// The output directory only exists once a run has delivered into it,
// so unreadable reads as empty rather than as an error.
if (!cancelled) setOutputEntries([]);
})
.finally(() => {
if (!cancelled) setOutputLoading(false);
});
return () => {
cancelled = true;
};
}, [outputDirectory]);
// What is running right now — polled while the master folder is open so
// the Processing section and its count stay live.
const [activeRuns, setActiveRuns] = useState<ProcessingRunInfo[] | null>(
null,
);
const processingRecordId = currentProcessing?.id;
const { listActiveRuns } = processingApi;
useEffect(() => {
if (!outputDirectory || !processingRecordId) {
setActiveRuns(null);
return;
}
let cancelled = false;
const tick = async () => {
const runs = await listActiveRuns(processingRecordId);
if (!cancelled) setActiveRuns(runs);
};
void tick();
const timer = setInterval(() => void tick(), 3000);
return () => {
cancelled = true;
clearInterval(timer);
};
}, [outputDirectory, processingRecordId, listActiveRuns]);
const openProcessingSection = useCallback(
(id: ProcessingSectionId) => {
if (!currentFolderId) return;
navigate(`/files/${currentFolderId}?section=${id}`);
},
[navigate, currentFolderId],
);
const clearProcessingSection = useCallback(() => {
if (!currentFolderId) return;
navigate(`/files/${currentFolderId}`);
}, [navigate, currentFolderId]);
// Opening a disk file loads its bytes into the workbench — the one moment
// anything leaves the disk, and only because the user asked to work on it.
const openDiskFile = useCallback(
@@ -524,10 +618,58 @@ export default function FileManagerView() {
"modified-asc": (a, b) => a.lastModified - b.lastModified,
"modified-desc": (a, b) => b.lastModified - a.lastModified,
};
return diskEntries
.filter((disk) => !needle || disk.name.toLowerCase().includes(needle))
.sort(compare[filesPage.sortMode] ?? compare["modified-desc"]!)
.map<FilesPageEntry>((disk) => ({ kind: "diskFile", disk }));
const toDiskEntries = (list: DiskFileEntry[]) =>
list
.filter((disk) => !needle || disk.name.toLowerCase().includes(needle))
.sort(compare[filesPage.sortMode] ?? compare["modified-desc"]!)
.map<FilesPageEntry>((disk) => ({ kind: "diskFile", disk }));
// A processing folder's root is its three sections; a search cuts
// through them straight to the originals.
if (outputDirectory && processingSection === null && !inSearch) {
return [
{
kind: "section",
section: {
id: "inputs",
count: diskLoading ? null : diskEntries.length,
},
},
{
kind: "section",
section: {
id: "outputs",
count: outputLoading ? null : outputEntries.length,
},
},
{
kind: "section",
section: {
id: "processing",
count: activeRuns === null ? null : activeRuns.length,
},
},
];
}
if (processingSection === "outputs") {
return toDiskEntries(outputEntries);
}
if (processingSection === "processing") {
return (activeRuns ?? [])
.filter(
(run) =>
!needle || (run.fileName ?? "").toLowerCase().includes(needle),
)
.map<FilesPageEntry>((run) => ({
kind: "run",
run: {
runId: run.runId,
fileName: run.fileName ?? "…",
currentStep: run.currentStep,
stepCount: run.stepCount,
},
}));
}
return toDiskEntries(diskEntries);
}
return [
...visibleFolders.map<FilesPageEntry>((folder) => ({
@@ -556,6 +698,12 @@ export default function FileManagerView() {
currentFolderId,
currentLocalDirectory,
diskEntries,
diskLoading,
outputDirectory,
processingSection,
outputEntries,
outputLoading,
activeRuns,
filesPage.sortMode,
pathForFolderId,
]);
@@ -1015,7 +1163,12 @@ export default function FileManagerView() {
<div className="files-page" ref={dropZoneRef}>
<header className="files-page-header">
{/* Breadcrumb only for folder-rooted tabs. */}
{(currentTab === "all" || currentTab === "cloud") && <Breadcrumbs />}
{(currentTab === "all" || currentTab === "cloud") && (
<Breadcrumbs
section={processingSection}
onClearSection={clearProcessingSection}
/>
)}
{(currentTab === "local" ||
currentTab === "recent" ||
currentTab === "shared" ||
@@ -1699,7 +1852,16 @@ export default function FileManagerView() {
>
<FileGrid
entries={entries}
loading={loading || diskLoading}
loading={
loading ||
// The master view's section cards render instantly (their
// counts fill in); only a section's own listing skeletons.
(outputDirectory && processingSection === null
? false
: processingSection === "outputs"
? outputLoading
: diskLoading)
}
currentTab={currentTab}
searchActive={search.trim().length > 0}
serverReachable={folders.serverReachable}
@@ -1711,6 +1873,7 @@ export default function FileManagerView() {
onSelectFile={handleSelectFile}
onSetSelection={setSelectedFileIds}
onOpenFolder={handleOpenFolder}
onOpenSection={openProcessingSection}
onOpenDiskFile={(entry) => void openDiskFile(entry)}
onOpenFile={handleOpenFile}
onMoveFiles={moveFilesTo}
@@ -1933,7 +2096,14 @@ export default function FileManagerView() {
);
}
function Breadcrumbs() {
function Breadcrumbs({
section,
onClearSection,
}: {
/** Active processing-folder section, appended as a trailing crumb. */
section?: ProcessingSectionId | null;
onClearSection?: () => void;
}) {
const { t } = useTranslation();
const folders = useFolders();
const filesPage = useFilesPage();
@@ -1944,13 +2114,21 @@ function Breadcrumbs() {
aria-label={t("filesPage.breadcrumbs", "Folder path")}
>
{trail.map((entry, idx) => {
const isLast = idx === trail.length - 1;
const isLast = idx === trail.length - 1 && !section;
// The current folder's crumb with a section open must clear the
// section: re-selecting the already-current folder is a no-op, so
// navigation is the only way back to the master view.
const isSectionParent = idx === trail.length - 1 && Boolean(section);
return (
<React.Fragment key={entry.id ?? "root"}>
<Button
variant="tertiary"
className={`files-page-breadcrumb${isLast ? " is-current" : ""}`}
onClick={() => folders.setCurrentFolderId(entry.id)}
onClick={() =>
isSectionParent
? onClearSection?.()
: folders.setCurrentFolderId(entry.id)
}
onDragOver={(e) => {
if (e.dataTransfer.types.includes(FILES_PAGE_DRAG_TYPE)) {
e.preventDefault();
@@ -2018,6 +2196,14 @@ function Breadcrumbs() {
</React.Fragment>
);
})}
{section && (
<Button variant="tertiary" className="files-page-breadcrumb is-current">
{t(
PROCESSING_SECTION_LABELS[section].key,
PROCESSING_SECTION_LABELS[section].fallback,
)}
</Button>
)}
</nav>
);
}
@@ -5,6 +5,17 @@ export interface ProcessingFolderState {
/** The processing record's own id — not the folder's. */
id: string;
enabled: boolean;
/** Where a disk-backed folder's results land, when the record names one. */
outputDirectory?: string;
}
/** One in-flight run of a processing folder, as the files page shows it. */
export interface ProcessingRunInfo {
runId: string;
/** The document being processed, when the run's source recorded a name. */
fileName: string | null;
currentStep: number;
stepCount: number;
}
export interface ProcessingFoldersApi {
@@ -14,6 +25,8 @@ export interface ProcessingFoldersApi {
enabledFolderIds: ReadonlySet<string>;
/** Whether any processing folder is enabled, whatever it watches. */
anyEnabled: boolean;
/** The record's runs that are currently executing (or queued to). */
listActiveRuns: (recordId: string) => Promise<ProcessingRunInfo[]>;
/** Attach the default (classification) pipeline to a folder. */
enable: (folder: FolderRecord) => Promise<void>;
/** Remove the processing behaviour; the folder and its files stay. */
@@ -35,6 +48,7 @@ export function useProcessingFolders(): ProcessingFoldersApi {
stateFor: () => undefined,
enabledFolderIds: EMPTY_IDS,
anyEnabled: false,
listActiveRuns: async () => [],
enable: async () => {},
disable: async () => {},
sweep: async () => {},
@@ -3,6 +3,7 @@ import {
CLASSIFY_OPERATION,
classificationDefaults,
deleteProcessingFolder,
fetchProcessingFolderRuns,
fetchProcessingFolders,
saveProcessingFolder,
sweepProcessingFolder,
@@ -18,6 +19,7 @@ import { folderKind, type FolderRecord } from "@app/types/folder";
import type {
ProcessingFolderState,
ProcessingFoldersApi,
ProcessingRunInfo,
} from "@core/hooks/useProcessingFolders";
// Consumers import the contract's types from @app, which resolves here in
@@ -25,6 +27,7 @@ import type {
export type {
ProcessingFolderState,
ProcessingFoldersApi,
ProcessingRunInfo,
} from "@core/hooks/useProcessingFolders";
/**
@@ -129,7 +132,16 @@ export function useProcessingFolders(): ProcessingFoldersApi {
: undefined;
}
const record = recordFor(folder);
return record ? { id: record.id, enabled: record.enabled } : undefined;
if (!record) return undefined;
const outputDirectory = record.output?.["directory"];
return {
id: record.id,
enabled: record.enabled,
outputDirectory:
typeof outputDirectory === "string" && outputDirectory
? outputDirectory
: undefined,
};
},
[recordFor],
);
@@ -210,6 +222,22 @@ export function useProcessingFolders(): ProcessingFoldersApi {
[recordFor, refreshFolders],
);
const listActiveRuns = useCallback(
async (recordId: string): Promise<ProcessingRunInfo[]> => {
const TERMINAL = ["COMPLETED", "FAILED", "CANCELLED"];
const runs = await fetchProcessingFolderRuns(recordId).catch(() => []);
return runs
.filter((run) => run.runId && !TERMINAL.includes(run.status))
.map((run) => ({
runId: run.runId!,
fileName: run.fileName ?? null,
currentStep: run.currentStep ?? 0,
stepCount: run.stepCount ?? 0,
}));
},
[],
);
const sweep = useCallback(
async (folder: FolderRecord) => {
if (folderKind(folder) === "virtual") {
@@ -231,8 +259,24 @@ export function useProcessingFolders(): ProcessingFoldersApi {
);
return useMemo(
() => ({ stateFor, enabledFolderIds, anyEnabled, enable, disable, sweep }),
[stateFor, enabledFolderIds, anyEnabled, enable, disable, sweep],
() => ({
stateFor,
enabledFolderIds,
anyEnabled,
listActiveRuns,
enable,
disable,
sweep,
}),
[
stateFor,
enabledFolderIds,
anyEnabled,
listActiveRuns,
enable,
disable,
sweep,
],
);
}
@@ -134,6 +134,10 @@ export interface ProcessingFolderRun {
status: string;
error?: string | null;
outputs?: ProcessingRunOutput[] | null;
/** The input document's display name, for runs whose source recorded one. */
fileName?: string | null;
currentStep?: number;
stepCount?: number;
}
/** Runs belonging to a processing folder, newest first — drives the progress display. */