From 3cab68ba2b4fa7dd36c92156d3b7a77ea1671ec3 Mon Sep 17 00:00:00 2001 From: "Brian J. Fox" Date: Wed, 4 Nov 2026 12:22:42 +0000 Subject: [PATCH 1/5] feat(hub): let the person take an attached file back A material stayed in the project for good. DELETE /projects/:projectId/materials/:artifactId archives it, so every list leaves it out while its versions stay, and tells the coordinator, when one runs, that the file is withdrawn. --- apps/hub/src/artifacts/routes.ts | 32 +++++++++++++++++++++++++ apps/hub/src/artifacts/service.ts | 29 +++++++++++++++++++++- apps/hub/src/config/materials.ts | 5 ++++ packages/client/src/client/artifacts.ts | 9 +++++++ packages/client/src/client/shared.ts | 8 +++++++ packages/schemas/src/artifact.ts | 8 +++++++ 6 files changed, 90 insertions(+), 1 deletion(-) diff --git a/apps/hub/src/artifacts/routes.ts b/apps/hub/src/artifacts/routes.ts index 3fd8334a..f441986a 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 material is archived, so it leaves the project's documents and the coordinator's filed materials while its versions stay, and the coordinator, when one runs, is told the file is withdrawn. The message it was sent with keeps showing it.", + 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..edb77dd7 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,31 @@ export function createArtifactService( return { materials }; }, + // A material the person takes back: archived, so every list leaves it out while its versions + // stay, and the coordinator told, when one runs, that the file is withdrawn. + 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) { + await setArtifactArchived(db, row, true); + const coordinator = await activeCoordinator(projectId); + if (coordinator) + await mailRun(coordinator.runId, materialRemovedNote(row.title)); + } + return { artifactId: row.id, title: row.title }; + }, + // 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/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..903d8c20 100644 --- a/packages/schemas/src/artifact.ts +++ b/packages/schemas/src/artifact.ts @@ -468,6 +468,14 @@ 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", +}); +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({ From 493deb1273acf6fea59bccf598f49e83ed71996c Mon Sep 17 00:00:00 2001 From: "Brian J. Fox" Date: Wed, 4 Nov 2026 12:22:42 +0000 Subject: [PATCH 2/5] feat(web): offer Remove attachment in a material tab's menu The tab's menu, beside printing, takes the attached file back; the tab goes with the next room read and the person hears so. --- apps/web/src/api/artifacts.ts | 6 +++++ apps/web/src/config.ts | 6 +++++ .../web/src/pages/workspace/artifact-pane.tsx | 17 ++++++++++++- apps/web/src/pages/workspace/index.tsx | 24 +++++++++++++++++++ 4 files changed, 52 insertions(+), 1 deletion(-) 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..43cba10b 100644 --- a/apps/web/src/config.ts +++ b/apps/web/src/config.ts @@ -914,6 +914,12 @@ export const wording = { passage: { addToPrompt: "Add to prompt" }, documentBar: "Document controls", printDocument: "Save as PDF or print", + removeAttachment: { + menu: "Remove attachment", + done: (name: string) => + `${name} is removed; the coordinator has been told.`, + 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..1c6a64c5 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; @@ -644,6 +650,15 @@ export function ArtifactPane({