diff --git a/apps/hub/src/artifacts/routes.ts b/apps/hub/src/artifacts/routes.ts index 3fd8334a..e3fe47a2 100644 --- a/apps/hub/src/artifacts/routes.ts +++ b/apps/hub/src/artifacts/routes.ts @@ -27,6 +27,7 @@ import { LiveApp, MAX_APPROVAL_PACKAGE_BYTES, MaterialReceipt, + MaterialRemovedReceipt, } from "@corbits-builder/schemas/artifact"; import { ProjectActivity } from "@corbits-builder/schemas/activity"; import { @@ -398,6 +399,37 @@ export function mountArtifactRoutes( ); }, ); + app.delete( + "/:projectId/materials/:artifactId", + describeRoute({ + tags: ["Artifacts"], + operationId: "removeMaterial", + description: + "Takes an attached file back. The coordinator, when one runs, is told the file is withdrawn first, so a note that cannot be sent leaves the material in place for another try; then the material is archived, so it leaves the project's documents and the coordinator's filed materials while its versions stay. The message it was sent with keeps showing it. `told` says whether a coordinator was mailed.", + security, + responses: { + 200: { + description: "The material taken back", + content: { + "application/json": { schema: resolver(MaterialRemovedReceipt) }, + }, + }, + 404: { description: "No such material in this project" }, + }, + }), + async (c) => { + const current = actorOf(c); + return c.json( + await service.removeMaterial( + current, + c.req.param("projectId"), + c.req.param("artifactId"), + (runId, content) => + mailRunAsCaller(c, nativeFetch, current.tenantId, runId, content), + ), + ); + }, + ); app.post( "/:projectId/design-concepts", describeRoute({ diff --git a/apps/hub/src/artifacts/service.ts b/apps/hub/src/artifacts/service.ts index b69615b6..bd78ae16 100644 --- a/apps/hub/src/artifacts/service.ts +++ b/apps/hub/src/artifacts/service.ts @@ -11,6 +11,7 @@ import { listArtifacts, listArtifactVersions, MAX_LIST_LIMIT, + setArtifactArchived, VersionConflictError, writeArtifactVersion, type ArtifactDb, @@ -54,6 +55,7 @@ import { type AcceptedMaterials, type UserManual, type MaterialReceipt, + MaterialRemovedReceipt, type DesignTweaksReceipt, type DesignChoiceReceipt, type LiveApp, @@ -144,7 +146,7 @@ import { import { awaitingApproval } from "../projects/deploy-tools.ts"; import { frozenModelChoice, workflowBindings } from "../config/models.ts"; import { modelDirectory } from "../settings/custom-providers.ts"; -import { materialsNote } from "../config/materials.ts"; +import { materialRemovedNote, materialsNote } from "../config/materials.ts"; import { servingOffering } from "../workflows/install-for-tenant.ts"; import { acceptedMaterials, @@ -1248,6 +1250,36 @@ export function createArtifactService( return { materials }; }, + // A material the person takes back: the coordinator, when one runs, is told first, so a note + // that cannot be sent leaves the material in place for another try; then it is archived, so + // every list leaves it out while its versions stay. + async removeMaterial( + actor: Actor, + projectId: string, + artifactId: string, + mailRun: (runId: string, content: string) => Promise, + ): Promise { + const allowed = await requireProject(actor, projectId, "manage"); + if (!(await allowed(artifactResource(MATERIAL_KIND), "write"))) + throw new ProjectError(403, "artifact_write_refused", { + kind: MATERIAL_KIND, + }); + const row = await getArtifact(db, artifactId); + if (!row || row.tenantId !== actor.tenantId || row.kind !== MATERIAL_KIND) + throw new ProjectError(404, "material_not_found", { artifactId }); + if (row.archivedAt !== null) + return { artifactId: row.id, title: row.title, told: false }; + const coordinator = await activeCoordinator(projectId); + if (coordinator) + await mailRun(coordinator.runId, materialRemovedNote(row.title)); + await setArtifactArchived(db, row, true); + return { + artifactId: row.id, + title: row.title, + told: coordinator !== null, + }; + }, + // An imported document with no kind of its own, filed as a material without mail: the // import's opening message names it to the coordinator. async fileMaterial( diff --git a/apps/hub/src/config/materials.ts b/apps/hub/src/config/materials.ts index ae58c6b4..5ff8910f 100644 --- a/apps/hub/src/config/materials.ts +++ b/apps/hub/src/config/materials.ts @@ -3,3 +3,8 @@ export function materialsNote(names: readonly string[]): string { return `I attached ${names.join(", ")}. Each is filed as a material.`; } + +// The person's message when they take an attached file back; the material is archived with it. +export function materialRemovedNote(name: string): string { + return `I removed the attached file ${name}. It is no longer among the materials, so leave it out from here on.`; +} diff --git a/apps/web/src/api/artifacts.ts b/apps/web/src/api/artifacts.ts index 2c8dc5f9..a84858d9 100644 --- a/apps/web/src/api/artifacts.ts +++ b/apps/web/src/api/artifacts.ts @@ -219,6 +219,12 @@ export async function attachMaterials( return productClient().attachMaterials(tenantId, projectId, files, message); } +/** Takes an attached file back; the coordinator is told and the file leaves the documents. */ +export async function removeMaterial(projectId: string, artifactId: string) { + const tenantId = await workspaceTenantId(); + return productClient().removeMaterial(tenantId, projectId, artifactId); +} + export async function downloadPackageZip( projectId: string, version: number, diff --git a/apps/web/src/config.ts b/apps/web/src/config.ts index 95b3f852..a9335714 100644 --- a/apps/web/src/config.ts +++ b/apps/web/src/config.ts @@ -914,6 +914,15 @@ export const wording = { passage: { addToPrompt: "Add to prompt" }, documentBar: "Document controls", printDocument: "Save as PDF or print", + removeAttachment: { + menu: "Remove attachment…", + confirm: "Yes, remove it", + done: (name: string, told: boolean) => + told + ? `${name} is removed; the coordinator has been told.` + : `${name} is removed.`, + failed: "The attachment could not be removed.", + }, versions: { menu: (version: number) => `Version ${String(version)}, choose a version`, comparePrevious: "Compare with previous", diff --git a/apps/web/src/pages/workspace/artifact-pane.tsx b/apps/web/src/pages/workspace/artifact-pane.tsx index 9cdad632..d64db8cf 100644 --- a/apps/web/src/pages/workspace/artifact-pane.tsx +++ b/apps/web/src/pages/workspace/artifact-pane.tsx @@ -1,4 +1,4 @@ -import { FileText, Printer } from "lucide-react"; +import { FileText, Printer, Trash2 } from "lucide-react"; import { useEffect, useRef, @@ -81,6 +81,9 @@ function filterVisibleDeliverables( const TAB_PREFIX = "workspace-document"; const DRAFT_TAB = "design-draft"; +// The page names each attached file's tab "material:"; only those can be taken back. +export const MATERIAL_TAB_PREFIX = "material:"; +const removable = (id: string) => id.startsWith(MATERIAL_TAB_PREFIX); const draftIds = panelTabIds(TAB_PREFIX, DRAFT_TAB); const MANUAL_TAB = "user-manual"; const manualIds = panelTabIds(TAB_PREFIX, MANUAL_TAB); @@ -249,6 +252,7 @@ export function ArtifactPane({ selected, state, onSelect, + onRemove, paneRef, changesOpen, onToggleChanges, @@ -278,6 +282,8 @@ export function ArtifactPane({ selected: Deliverable | null; state: DeliverableState | undefined; onSelect: (id: string) => void; + /** Takes an attached file back, for a tab that is one; absent where nothing can be removed. */ + onRemove?: ((id: string) => void) | undefined; paneRef?: Ref; changesOpen: boolean; onToggleChanges: () => void; @@ -422,8 +428,11 @@ export function ArtifactPane({ y: number; } | null>(null); const printing = useRef(null); + // Removing takes two clicks, both in the menu: the second item only exists after the first. + const [confirmingRemove, setConfirmingRemove] = useState(false); const openTabMenu = (value: string, x: number, y: number) => { choose(value); + setConfirmingRemove(false); setTabMenu({ value, x, y }); }; const tabMenuProps = (value: string) => ({ @@ -452,6 +461,29 @@ export function ArtifactPane({ if (item !== undefined && pane !== null) printDocument(pane, item.title, documentStatus(item)); }; + // The menu's remove item for an attached file's tab: a first click asks, the second removes. + const removeItem = (value: string) => { + if (onRemove === undefined || !removable(value)) return null; + if (confirmingRemove) + return ( + onRemove(value)}> + + ); + return ( + { + event.preventDefault(); + setConfirmingRemove(true); + }} + > + + ); + }; const outputs = visibleDeliverables.length > 0 || draftShown || conceptViews.length > 0; const hasTabs = outputs || themes !== null; @@ -619,7 +651,11 @@ export function ArtifactPane({ {tabMenu === null ? null : ( (open ? undefined : setTabMenu(null))} + onOpenChange={(open) => { + if (open) return; + setTabMenu(null); + setConfirmingRemove(false); + }} > )} diff --git a/apps/web/src/pages/workspace/index.tsx b/apps/web/src/pages/workspace/index.tsx index 25210b63..bad69f36 100644 --- a/apps/web/src/pages/workspace/index.tsx +++ b/apps/web/src/pages/workspace/index.tsx @@ -60,6 +60,7 @@ import { subscribeWorkflowRun } from "../../api/run-stream.ts"; import { acceptedMaterials, attachMaterials, + removeMaterial, readArtifact, } from "../../api/artifacts.ts"; import { documentsForPane } from "./document-read.ts"; @@ -797,6 +798,29 @@ export function StageWorkspace({ }; const chooseDocument = (id: string) => setDocumentChoice({ id, filedCount: filed.length }); + // An attached file taken back: its tab goes with the next room read, and the person hears so. + const removeAttachment = (id: string) => { + if (!id.startsWith(MATERIAL_TAB)) return; + const artifactId = id.slice(MATERIAL_TAB.length); + const item = paneDeliverables.find((entry) => entry.kind === id); + void takeBack(artifactId, item?.title); + }; + const takeBack = async (artifactId: string, title: string | undefined) => { + try { + const removed = await removeMaterial(projectId, artifactId); + setDocumentChoice(null); + toast.success( + wording.removeAttachment.done( + title === undefined ? removed.title : title, + removed.told, + ), + ); + reloadRef.current(); + onChanged(); + } catch (cause) { + toast.error(humanFault(cause, wording.removeAttachment.failed)); + } + }; const openDocument = (id: string) => { setPaneTab({ value: id }); if (conceptOfTab(id) === undefined) chooseDocument(id); @@ -1595,6 +1619,7 @@ export function StageWorkspace({ } : {})} onSelect={chooseDocument} + onRemove={removeAttachment} changesOpen={selected !== null && changesFor === selected.kind} onToggleChanges={() => setChangesFor((current) => diff --git a/packages/client/src/client/artifacts.ts b/packages/client/src/client/artifacts.ts index d8558cf7..e3d74afe 100644 --- a/packages/client/src/client/artifacts.ts +++ b/packages/client/src/client/artifacts.ts @@ -29,6 +29,7 @@ export function buildArtifactsMethods( | "chooseDesign" | "acceptedMaterials" | "attachMaterials" + | "removeMaterial" | "downloadPackageZip" | "approvalRounds" | "startApprovalRound" @@ -169,6 +170,14 @@ export function buildArtifactsMethods( signal, ); }, + removeMaterial(tenantId, projectId, artifactId, signal) { + return request( + `${projectPath(tenantId, projectId)}/materials/${encodeURIComponent(artifactId)}`, + signal, + null, + "DELETE", + ); + }, async attachMaterials(tenantId, projectId, files, message, signal) { const body = new FormData(); for (const file of files) body.append("file", file); diff --git a/packages/client/src/client/shared.ts b/packages/client/src/client/shared.ts index 8179414c..bda6a08b 100644 --- a/packages/client/src/client/shared.ts +++ b/packages/client/src/client/shared.ts @@ -61,6 +61,7 @@ import type { InFlightRun, LiveApp, MaterialReceipt, + MaterialRemovedReceipt, } from "@corbits-builder/schemas/artifact"; import type { ApprovalRound, @@ -544,6 +545,13 @@ export type ProductApiClient = Readonly<{ message?: string, signal?: AbortSignal, ): Promise; + /** Takes an attached file back: the material is archived and the coordinator told. */ + removeMaterial( + tenantId: string, + projectId: string, + artifactId: string, + signal?: AbortSignal, + ): Promise; /** A filed approval_package version as a deterministic zip of its deck, PDF, README, manifest and documents. */ downloadPackageZip( tenantId: string, diff --git a/packages/schemas/src/artifact.ts b/packages/schemas/src/artifact.ts index 7357fb6a..b0756ee2 100644 --- a/packages/schemas/src/artifact.ts +++ b/packages/schemas/src/artifact.ts @@ -468,6 +468,16 @@ export const MaterialReceipt = type({ }); export type MaterialReceipt = typeof MaterialReceipt.infer; +// A material the person took back: archived, so it leaves every list while its versions stay. +export const MaterialRemovedReceipt = type({ + "+": "reject", + artifactId: "string > 0", + title: "string", + // Whether a running coordinator was told the file is withdrawn. + told: "boolean", +}); +export type MaterialRemovedReceipt = typeof MaterialRemovedReceipt.infer; + // The attachment types the project's coordinator model reads, and Interchange's attachment limits. // Empty when the model's catalog offering declares neither vision-input nor document-input. export const AcceptedMaterials = type({