fix(ui): remove stored-file browsing entry points

Defer cached-file debugging and live sandbox inspection as separate features. Keep the saved-file browser only as an unshipped Storybook prototype.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-09-08 09:17:07 -05:00
parent bd4b566f96
commit 71774a29ca
10 changed files with 68 additions and 174 deletions

View File

@ -132,11 +132,16 @@ directories are not supported by this checkpoint format.
## API and UI
The task, agent, project, and current-user pages expose a Files dialog using the
shared file tree and viewer. It supports uploads, folder creation, previews,
downloads, deletion, trash restore/purge, and sync state with the last agent save
time. Direct file-operation timestamps are labeled separately as “Files updated”;
a delete, restore, or idempotent receipt is not presented as an agent checkpoint.
The task, agent, project, and current-user pages do not expose stored-file
browsing. The 2026-09-08 design review removed these entry points: durable copies
can lag behind a running sandbox and do not represent its complete filesystem.
The persistence and synchronization APIs remain available to the runtime.
Two separate features are deferred: debug inspection of persisted collections,
and authorized inspection of the live sandbox filesystem across its task,
agent, user, project, and repository context. The stored-file browser remains
only as an unshipped Storybook prototype. It must not be presented as a live
sandbox view. Page stories show the current pages without Files buttons.
All routes start at
`/api/companies/:companyId/work-folders/:scope/:ownerId`:
@ -166,7 +171,9 @@ new pinned staging stack with the branch's Cloud image and matching migrator.
The deployed harness must target that tenant URL without launching a local
server. Enumerate every sandbox-capable adapter/engine and native profile
exposed by the stack; missing credentials or skipped required profiles block
acceptance. Record real browser operations, two actual 180-second intervals,
acceptance. Verify that the removed stored-file entry points are absent from
the UI; browser-based file-management acceptance is deferred with the separate
inspection features. Record API/runner operations, two actual 180-second intervals,
short-run flushes, independent task checkouts, identity/privacy boundaries,
interrupted saves, and recovery without the original sandbox or app volume.

View File

@ -1,6 +1,6 @@
import { useId, useMemo, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Download, FolderOpen, FolderPlus, RefreshCw, RotateCcw, Trash2, Upload } from "lucide-react";
import { Download, FolderPlus, RefreshCw, RotateCcw, Trash2, Upload } from "lucide-react";
import type { WorkFile, WorkFolderOwner } from "@paperclipai/shared";
import { workFoldersApi } from "@/api/work-folders";
import { FileTree, type FileTreeNode } from "@/components/FileTree";
@ -9,7 +9,6 @@ import { Button } from "@/components/ui/button";
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
function tree(files: WorkFile[]) {
const root: FileTreeNode = { name: "", path: "", kind: "dir", children: [] };
@ -30,16 +29,6 @@ function tree(files: WorkFile[]) {
sort(root.children); return root.children;
}
export function WorkFolderButton({ owner, label = "Files" }: { owner: WorkFolderOwner; label?: string }) {
return <Dialog><DialogTrigger asChild><Button variant="outline" size="sm"><FolderOpen aria-hidden />{label}</Button></DialogTrigger>
<DialogContent className="flex max-h-screen flex-col sm:max-w-5xl">
<DialogHeader><DialogTitle>{label}</DialogTitle><DialogDescription>
{owner.scope === "user" ? "Your private Paperclip files in this company." : `Files shared with this ${owner.scope}'s sandbox runs.`} Changes from running agents are saved every three minutes and when a run ends.
</DialogDescription></DialogHeader>
<WorkFolderBrowser key={`${owner.companyId}:${owner.scope}:${owner.ownerId}`} owner={owner} />
</DialogContent></Dialog>;
}
export function WorkFolderBrowser({ owner, exampleFiles }: { owner: WorkFolderOwner; exampleFiles?: WorkFile[] }) {
const queryClient = useQueryClient();
const [trash, setTrash] = useState(false);

View File

@ -59,7 +59,6 @@ import { cn } from "../lib/utils";
import { describeRunRetryState } from "../lib/runRetryState";
import { Button } from "@/components/ui/button";
import { Tabs } from "@/components/ui/tabs";
import { WorkFolderButton } from "@/components/WorkFolderBrowser";
import { PageTabBar } from "../components/PageTabBar";
import { AuditFeed } from "./audit/AuditFeed";
import { Skeleton } from "@/components/ui/skeleton";
@ -1374,8 +1373,6 @@ export function AgentDetail() {
</Tabs>
) : null}
<WorkFolderButton owner={{ companyId: agent.companyId, scope: "agent", ownerId: agent.id }} label="Agent files" />
{actionError && <p className="text-sm text-destructive">{actionError}</p>}
{isPendingApproval && (
<div className="flex flex-wrap items-center gap-3 rounded-md border border-amber-300/60 bg-amber-50 px-3 py-2 text-sm text-amber-900 dark:border-amber-400/40 dark:bg-amber-950/30 dark:text-amber-200">

View File

@ -1,5 +1,4 @@
import { useState } from "react";
import { WorkFolderBrowser } from "@/components/WorkFolderBrowser";
import { ServicesList } from "./apps/app-detail/ServicesPanel";
import { ComposioProvenanceChip } from "./apps/ComposioProvenanceChip";
import type { ComposioServiceRow } from "./apps/composio-services";
@ -483,14 +482,6 @@ export function DesignGuide() {
{/* ============================================================ */}
{/* COVERAGE */}
{/* ============================================================ */}
<Section title="Work folders">
<WorkFolderBrowser owner={{ companyId: "example", scope: "task", ownerId: "example" }} exampleFiles={[
{ id: "notes", path: "notes", kind: "directory", byteSize: 0, sha256: null, executable: false, contentType: "application/octet-stream", deletedAt: null, updatedAt: "2026-09-07T00:00:00Z" },
{ id: "readme", path: "notes/README.md", kind: "file", byteSize: 24, sha256: null, executable: false, contentType: "text/markdown", deletedAt: null, updatedAt: "2026-09-07T00:00:00Z" },
]} />
<WorkFolderBrowser owner={{ companyId: "example", scope: "user", ownerId: "example" }} exampleFiles={[]} />
</Section>
<Section title="Component Coverage">
<p className="text-sm text-muted-foreground">
This page should be updated when new UI primitives or app-level patterns ship.

View File

@ -171,7 +171,6 @@ import { useStreamlinedUiEnabled } from "../hooks/useStreamlinedUiEnabled";
import { workModeMetaFor } from "../lib/work-mode-meta";
import { IssueContinuationHandoff } from "../components/IssueContinuationHandoff";
import { IssueAttachmentsSection } from "../components/IssueAttachmentsSection";
import { WorkFolderButton } from "@/components/WorkFolderBrowser";
import { IssueDocumentsSection } from "../components/IssueDocumentsSection";
import { IssuePlanDecompositionsSection } from "../components/IssuePlanDecompositionsSection";
import { IssueOutputSection } from "../components/issue-output/IssueOutputSection";
@ -7470,8 +7469,6 @@ export function IssueDetail() {
/>
)}
<WorkFolderButton owner={{ companyId: issue.companyId, scope: "task", ownerId: issue.id }} label="Task files" />
{taskChatShellEnabled ? null : (
<IssueOutputSection
workProducts={workProducts}

View File

@ -13,7 +13,6 @@ import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { WorkFolderButton } from "@/components/WorkFolderBrowser";
function deriveInitials(name: string) {
const parts = name.trim().split(/\s+/).filter(Boolean);
@ -272,7 +271,6 @@ export function ProfileSettings() {
</form>
<InboxAgentPolicyControl companyId={selectedCompanyId} />
{selectedCompanyId && <WorkFolderButton owner={{ companyId: selectedCompanyId, scope: "user", ownerId: sessionQuery.data.user.id }} label="My files" />}
</section>
</div>
);

View File

@ -37,7 +37,6 @@ import { Input } from "@/components/ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { cn } from "@/lib/utils";
import { Tabs } from "@/components/ui/tabs";
import { WorkFolderButton } from "@/components/WorkFolderBrowser";
import { PluginLauncherOutlet } from "@/plugins/launchers";
import { PluginSlotMount, PluginSlotOutlet, usePluginSlots } from "@/plugins/slots";
import {
@ -822,7 +821,6 @@ export function ProjectDetail() {
) : null}
</div>
<div className="ml-auto flex items-center gap-2">
<WorkFolderButton owner={{ companyId: project.companyId, scope: "project", ownerId: project.id }} label="Project files" />
<StarToggle
size="button"
starred={projectStarred}

View File

@ -3,53 +3,48 @@
Run `pnpm storybook` from the repository root. Open the **Work folders** group.
The default URL is `http://localhost:6006`.
## Components
## Current pages
`Work folders / Components` renders the production `WorkFolderBrowser` and
`WorkFolderButton`. It includes the browser, its trigger and dialog, Markdown,
code, image and empty-file previews, unsupported/large-file messages, loading,
empty, saving, failed-save, unavailable-storage, failed-upload, trash, and
permanent-deletion confirmation states. `Upload Delete Restore` exercises the
full recovery interaction and leaves the restored file available for review.
The 2026-09-08 design decision removes stored-file entry points from task, agent,
project, and profile pages. Saved copies can lag behind the running sandbox and
are not a complete view of what the agent sees on disk. No replacement Files
link is added to the properties pane.
**Work folders / Pages** mounts the actual pages inside `Layout`: Task Page,
Agent Page, Project Page, Profile Settings Page, and Mobile Task Page. These show
the current UI without the removed buttons. The previous open-dialog stories
have been removed.
## Deferred inspection features
Debug inspection of persisted files and live sandbox filesystem browsing need
separate designs. Persistence, checkpointing, and the runtime APIs remain in
place; this UI change does not remove saved data or alter synchronization.
**Work folders / Stored-file prototype** retains the reusable browser for design
reference only. It is explicitly labeled as unshipped and has no entry point in
the application or Design Guide. It includes Markdown, code, image and empty-file
previews, unsupported/large-file messages, loading, empty, saving, failed-save,
unavailable-storage, failed-upload, trash, and permanent-deletion states.
Use **Controls** to change task/agent/project/user scope or the fixture state.
Use Storybook's theme and viewport toolbar controls for light/dark and mobile
review. The fixtures reset when you reload the story or change its controls.
## Pages
`Work folders / Pages` mounts the actual route pages inside `Layout`:
| Page | Current entry point | Stories |
| ---------------- | ---------------------------------------------------- | ------------------------------------------------------ |
| Task detail | Task files above the task thread | Task Page, Task Files Open, Task Save Failed |
| Agent detail | Agent files in the detail header | Agent Page, Agent Files Open |
| Project detail | Project files in the project header | Project Page, Project Files Open, Project Empty Folder |
| Profile settings | My files below the profile and inbox policy settings | Profile Settings Page, My Files Open |
The page stories preserve existing placement, copy, and spacing. They are a
review surface for the current implementation, not proposed redesigns. The
Open stories activate the page's real button automatically. Close the dialog
to review its surrounding page.
The toolbar supports light/dark and mobile review. Fixtures reset when you reload
the story or change its controls.
## Fixture boundaries
Uploads, previews, downloads, folder creation, deletion, restoration, purge,
and refresh use a fresh in-memory API per story. They do not contact a tenant,
launch agents, or persist files. Downloads contain fixture data only. Other
page mutations display an explicit unsupported-demo message. The story restores
its fetch handler and clears its isolated query cache on unmount. The loading
scenario intentionally remains pending until you leave that story.
Prototype uploads, previews, downloads, folder creation, deletion, restoration,
purge, and refresh use fresh in-memory data. They do not contact a tenant, launch
agents, or persist files. Other page mutations display an unsupported-demo
message. The story restores its fetch handler and clears its query cache on
unmount. The loading example remains pending until you leave the story.
Saving and failed states are fixed examples for visual review. Refresh shows the
real acknowledgement, but does not launch a sandbox. These stories do not replace
the deployed persistence and runner acceptance matrix.
Saving and failed states are fixed visual examples. Refresh shows the real
acknowledgement but does not launch a sandbox. These prototypes do not replace
runtime persistence testing or implement either deferred inspection feature.
## Supporting UI
The feature stack also changes the native runner's Pi ACPX selector and
Cloud-aware CLI authorization. **Work folders / Supporting UI** shows the real
runtime fields and the request, approved, sign-in-required, and expired CLI
pages. These use fictional challenges and cannot grant access or create keys.
The file viewer's shared rendering is covered by the component preview stories;
the design-guide examples use the same browser component.
**Work folders / Supporting UI** shows the native Pi ACPX selector and the
request, approved, sign-in-required, and expired CLI authorization pages. The
fictional challenges cannot grant access or create keys.

View File

@ -1,6 +1,5 @@
import { useEffect } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, userEvent, within } from "storybook/test";
import { Route, Routes, useLocation, useNavigate } from "react-router-dom";
import { PluginLauncherProvider } from "@/plugins/launchers";
import { Layout } from "@/components/Layout";
@ -15,11 +14,7 @@ import {
workFolderProject,
workFolderTask,
} from "../fixtures/WorkFolderStoryProvider";
import {
WORK_FOLDER_COMPANY,
workFolderLabels,
type WorkFolderScenario,
} from "../fixtures/workFolders";
import { WORK_FOLDER_COMPANY } from "../fixtures/workFolders";
import type { WorkFolderScope } from "@paperclipai/shared";
const paths = {
@ -64,77 +59,25 @@ const meta = {
docs: {
description: {
component:
"Real production pages inside the application Layout, including sidebar, breadcrumb, and original file-button placement. These are current designs, not redesigned mockups. Task files are in the task thread, Agent files and Project files are in their detail pages, and My files is at the bottom of Profile settings. The Open stories click the actual entry point. Work-folder actions use disposable in-memory data; other page edits are not simulated.",
"Current production pages after removing the stored-file entry points. Persisted copies do not represent the live sandbox filesystem. Debug inspection of saved files and live sandbox browsing are deferred features. Page mutations are not simulated.",
},
},
},
args: { scope: "task", scenario: "saved" },
argTypes: {
scope: { control: false },
scenario: {
control: "select",
options: ["saved", "saving", "failed", "empty", "unavailable"],
},
},
render: ({
scope,
scenario,
}: {
scope: WorkFolderScope;
scenario: WorkFolderScenario;
}) => (
<WorkFolderStoryProvider key={`${scope}:${scenario}`} scenario={scenario}>
args: { scope: "task" },
argTypes: { scope: { control: false } },
render: ({ scope }: { scope: WorkFolderScope }) => (
<WorkFolderStoryProvider key={scope}>
<Page scope={scope} />
</WorkFolderStoryProvider>
),
} satisfies Meta<{ scope: WorkFolderScope; scenario: WorkFolderScenario }>;
} satisfies Meta<{ scope: WorkFolderScope }>;
export default meta;
type Story = StoryObj<typeof meta>;
const openFiles: NonNullable<Story["play"]> = async ({
canvasElement,
args,
}) => {
const label = workFolderLabels[args.scope];
await userEvent.click(
await within(canvasElement).findByRole(
"button",
{ name: label },
{ timeout: 15000 },
),
);
await expect(
await within(canvasElement.ownerDocument.body).findByRole("dialog", {
name: label,
}),
).toBeVisible();
};
export const TaskPage: Story = { args: { scope: "task" } };
export const TaskFilesOpen: Story = {
args: { scope: "task" },
play: openFiles,
};
export const AgentPage: Story = { args: { scope: "agent" } };
export const AgentFilesOpen: Story = {
args: { scope: "agent" },
play: openFiles,
};
export const ProjectPage: Story = { args: { scope: "project" } };
export const ProjectFilesOpen: Story = {
args: { scope: "project" },
play: openFiles,
};
export const ProfileSettingsPage: Story = { args: { scope: "user" } };
export const MyFilesOpen: Story = { args: { scope: "user" }, play: openFiles };
export const TaskSaveFailed: Story = {
args: { scope: "task", scenario: "failed" },
play: openFiles,
};
export const ProjectEmptyFolder: Story = {
args: { scope: "project", scenario: "empty" },
play: openFiles,
};
export const MobileTaskFilesOpen: Story = {
export const MobileTaskPage: Story = {
args: { scope: "task" },
globals: { viewport: { value: "mobile" } },
play: openFiles,
};

View File

@ -1,12 +1,8 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, userEvent, within } from "storybook/test";
import {
WorkFolderBrowser,
WorkFolderButton,
} from "@/components/WorkFolderBrowser";
import { WorkFolderBrowser } from "@/components/WorkFolderBrowser";
import { WorkFolderStoryProvider } from "../fixtures/WorkFolderStoryProvider";
import {
workFolderLabels,
workFolderOwners,
type WorkFolderScenario,
} from "../fixtures/workFolders";
@ -15,20 +11,19 @@ import type { WorkFolderScope } from "@paperclipai/shared";
type Args = {
scope: WorkFolderScope;
scenario: WorkFolderScenario;
presentation: "browser" | "button";
};
const meta = {
title: "Work folders/Components",
title: "Work folders/Stored-file prototype",
parameters: {
layout: "padded",
docs: {
description: {
component:
"The actual reusable WorkFolderBrowser and WorkFolderButton. All file operations are in-memory fixtures: upload, create folder, delete, restore, purge, refresh, and download are safe to try. Change scope in Controls; use the toolbar for dark/light and mobile review. Reload a story to reset its files.",
"Unshipped stored-file browser prototype. Application entry points were removed because saved copies are not the live sandbox filesystem. This Storybook-only review surface uses disposable in-memory data; debug inspection and live sandbox browsing need separate designs.",
},
},
},
args: { scope: "task", scenario: "saved", presentation: "browser" },
args: { scope: "task", scenario: "saved" },
argTypes: {
scope: { control: "select", options: ["task", "agent", "project", "user"] },
scenario: {
@ -43,22 +38,15 @@ const meta = {
"uploadFailed",
],
},
presentation: { control: "radio", options: ["browser", "button"] },
},
render: ({ scope, scenario, presentation }) => (
<WorkFolderStoryProvider
key={`${scope}:${scenario}:${presentation}`}
scenario={scenario}
>
render: ({ scope, scenario }) => (
<WorkFolderStoryProvider key={`${scope}:${scenario}`} scenario={scenario}>
<div className="mx-auto max-w-5xl">
{presentation === "button" ? (
<WorkFolderButton
owner={workFolderOwners[scope]}
label={workFolderLabels[scope]}
/>
) : (
<WorkFolderBrowser owner={workFolderOwners[scope]} />
)}
<p className="mb-4 text-sm text-muted-foreground">
Stored-file prototype not exposed in the app. These are saved
copies, not the live sandbox filesystem.
</p>
<WorkFolderBrowser owner={workFolderOwners[scope]} />
</div>
</WorkFolderStoryProvider>
),
@ -67,15 +55,6 @@ export default meta;
type Story = StoryObj<typeof meta>;
export const BrowseAndManage: Story = {};
export const FileButton: Story = { args: { presentation: "button" } };
export const OpenDialog: Story = {
args: { presentation: "button" },
play: async ({ canvasElement }) => {
await userEvent.click(
await within(canvasElement).findByRole("button", { name: "Task files" }),
);
},
};
export const MarkdownPreview: Story = {
play: async ({ canvasElement }) => {
await userEvent.click(await within(canvasElement).findByText("README.md"));