diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts
index dc01dffb..aa8477ea 100644
--- a/apps/web/src/api.ts
+++ b/apps/web/src/api.ts
@@ -562,7 +562,13 @@ export interface StorageUsage {
database: StorageCategory;
workingComputerBrowserData: null;
};
- trash: { fileCount: number; sizeBytes: number; referencedFileCount: number };
+ trash: {
+ fileCount: number;
+ sizeBytes: number;
+ referencedFileCount: number;
+ /** C22; absent on an older 服务电脑. */
+ referencedSizeBytes?: number | undefined;
+ };
topChannels: Array<{
id: string;
name: string;
@@ -585,6 +591,32 @@ export async function getStorageUsage(signal?: AbortSignal): Promise("/api/v1/storage", signal ? { signal } : undefined);
}
+export interface GlobalTrashCleanupResult {
+ removed: number;
+ retained: Array<{
+ id: string;
+ name: string;
+ referenceCount: { messages: number; tasks: number };
+ }>;
+ retainedCount: number;
+ retainedHasMore: boolean;
+ freedBytes: number;
+ channelCount: number;
+}
+
+/**
+ * C22: empty every active channel's 回收站 except referenced files. Retry an unclear result with
+ * the same `requestKey`; the 服务电脑 replays the saved result instead of deleting again.
+ */
+export async function cleanupAllTrash(requestKey: string): Promise {
+ return request("/api/v1/storage/trash/cleanup", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ requestKey }),
+ signal: AbortSignal.timeout(120000),
+ });
+}
+
export async function getStorageSettings(signal?: AbortSignal): Promise {
return request("/api/v1/settings/storage", signal ? { signal } : undefined);
}
diff --git a/apps/web/src/channel-attachment-client.test.ts b/apps/web/src/channel-attachment-client.test.ts
index 392c8c5e..2a9602e7 100644
--- a/apps/web/src/channel-attachment-client.test.ts
+++ b/apps/web/src/channel-attachment-client.test.ts
@@ -6,6 +6,7 @@ import {
EMPTY_DOCUMENT_EXTRACT_ERROR,
EMPTY_PDF_EXTRACT_ERROR,
getAttachmentImage,
+ getAttachmentReferences,
getChannelAttachment,
presentAttachmentProcessError,
purgeAttachment,
@@ -148,6 +149,26 @@ describe("channel attachment display boundary", () => {
await expect(purgeAttachment(channel, "../escape")).rejects.toThrow("标识");
expect(fetchMock).toHaveBeenCalledTimes(3);
});
+ it("reads a file's references from the scoped route and reports a purged file", async () => {
+ const references = { messages: [], tasks: [], messageCount: 0, taskCount: 0, hasMore: false };
+ const fetchMock = vi
+ .fn()
+ .mockResolvedValueOnce(Response.json(references))
+ .mockResolvedValueOnce(
+ Response.json({ error: "attachment_purged", purged: true }, { status: 410 }),
+ );
+ vi.stubGlobal("fetch", fetchMock);
+ const signal = new AbortController().signal;
+ expect(await getAttachmentReferences(channel, id, signal)).toEqual(references);
+ expect(fetchMock.mock.calls[0]?.[0]).toBe(
+ `/api/v1/channels/${channel}/attachments/${id}/references?limit=20`,
+ );
+ await expect(getAttachmentReferences(channel, id, signal)).rejects.toBeInstanceOf(
+ AttachmentPurgedError,
+ );
+ await expect(getAttachmentReferences(channel, "../x", signal)).rejects.toThrow("标识");
+ expect(fetchMock).toHaveBeenCalledTimes(2);
+ });
it("rejects denied metadata before locking or reading a hostile body", async () => {
const pull = vi.fn(() => {
throw new Error("must not read denied content");
diff --git a/apps/web/src/channel-attachment-client.ts b/apps/web/src/channel-attachment-client.ts
index 9b9030d5..452fea45 100644
--- a/apps/web/src/channel-attachment-client.ts
+++ b/apps/web/src/channel-attachment-client.ts
@@ -268,6 +268,43 @@ export async function cleanupChannelTrash(
);
}
+/** C24: who still references a file. Previews are at most 120 characters with markers removed. */
+export interface AttachmentReferences {
+ messages: Array<{
+ id: string;
+ createdAt: string;
+ author: { kind: "owner" | "bot" | "system"; botId?: string };
+ preview: string;
+ }>;
+ tasks: Array<{ runId: string; title: string; status: string; createdAt: string }>;
+ messageCount: number;
+ taskCount: number;
+ hasMore: boolean;
+}
+
+export async function getAttachmentReferences(
+ channelId: string,
+ attachmentId: string,
+ signal: AbortSignal,
+ limit = 20,
+): Promise {
+ const response = await fetch(
+ `${attachmentPath(channelId, attachmentId)}/references?limit=${limit}`,
+ {
+ credentials: "include",
+ redirect: "error",
+ signal: AbortSignal.any([signal, AbortSignal.timeout(15000)]),
+ },
+ );
+ if (response.status === 410) throw new AttachmentPurgedError();
+ const data = JSON.parse(
+ new TextDecoder().decode(await readAvailableAttachment(response, 131072)),
+ ) as AttachmentReferences;
+ if (!Array.isArray(data.messages) || !Array.isArray(data.tasks))
+ throw new Error("引用列表格式不对。");
+ return data;
+}
+
export async function downloadAttachment(attachment: UploadedComposerAttachment): Promise {
const desktop = window.openbotDesktop;
if (desktop) {
diff --git a/apps/web/src/components/AttachmentsManager.css b/apps/web/src/components/AttachmentsManager.css
index 7688b0a8..a49cbd21 100644
--- a/apps/web/src/components/AttachmentsManager.css
+++ b/apps/web/src/components/AttachmentsManager.css
@@ -171,3 +171,61 @@
color: #8e8e93;
font-size: 12px;
}
+/* C24 查看引用: the messages and tasks that keep a 回收站 file, under its row. */
+.channel-files-references-toggle {
+ margin-left: 6px;
+ padding: 0;
+ border: 0;
+ background: transparent;
+ color: var(--ob-text-secondary);
+ font: inherit;
+ cursor: pointer;
+}
+.channel-files-references-toggle:hover {
+ color: var(--ob-text);
+}
+.channel-files-references {
+ grid-column: 2 / -1;
+ margin: 0;
+ padding: 4px;
+ list-style: none;
+ border-radius: 12px;
+ background: var(--ob-surface);
+}
+.channel-files-references.is-status,
+.channel-files-references .is-status {
+ padding: 8px 10px;
+ color: var(--ob-text-tertiary);
+ font-size: 12px;
+}
+.channel-files-references button {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ width: 100%;
+ padding: 7px 10px;
+ border: 0;
+ border-radius: 9px;
+ background: transparent;
+ color: inherit;
+ font: inherit;
+ text-align: left;
+ cursor: pointer;
+}
+.channel-files-references button:hover:not(:disabled) {
+ background: var(--ob-fill);
+}
+.channel-files-references button:disabled {
+ cursor: default;
+}
+.channel-files-references strong {
+ font-size: 12px;
+ font-weight: 600;
+}
+.channel-files-references small {
+ overflow: hidden;
+ color: var(--ob-text-secondary);
+ font-size: 12px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
diff --git a/apps/web/src/components/AttachmentsManager.test.tsx b/apps/web/src/components/AttachmentsManager.test.tsx
index c2e9d685..74710d1d 100644
--- a/apps/web/src/components/AttachmentsManager.test.tsx
+++ b/apps/web/src/components/AttachmentsManager.test.tsx
@@ -236,3 +236,73 @@ it("retries an unclear 清空回收站 with the same request key", async () => {
expect(keys[1]).toBe(keys[0]);
expect(document.body.textContent).toContain("已永久删除 1 个文件,释放 2 KB。");
});
+
+it("lists who references a kept file and opens a message or task from it", async () => {
+ const kept = {
+ ...upload("u333", "渠道数据-8月.xlsx", "2026-09-18T00:00:00.000Z"),
+ id: "00000000-0000-4000-8000-000000000033",
+ referenceCount: { messages: 2, tasks: 1 },
+ };
+ listed = [kept];
+ vi.mocked(fetch).mockImplementation(async (url) =>
+ String(url).includes("/references")
+ ? Response.json({
+ messages: [
+ {
+ id: "m-1",
+ createdAt: "2026-09-18T02:10:00.000Z",
+ author: { kind: "bot", botId: "bot-1" },
+ preview: "整理好了",
+ },
+ {
+ id: "m-2",
+ createdAt: "2026-09-17T02:10:00.000Z",
+ author: { kind: "system" },
+ preview: "例行任务已开始",
+ },
+ ],
+ tasks: [
+ {
+ runId: "run-9",
+ title: "汇总渠道",
+ status: "completed",
+ createdAt: "2026-09-16T00:00:00.000Z",
+ },
+ ],
+ messageCount: 2,
+ taskCount: 1,
+ hasMore: false,
+ })
+ : Response.json({ attachments: listed }),
+ );
+ const showMessage = vi.fn();
+ const showTask = vi.fn();
+ const view = await renderComponent(
+ (id === "bot-1" ? "研究助理" : undefined)}
+ onShowMessage={showMessage}
+ onShowTask={showTask}
+ onClose={vi.fn()}
+ />,
+ );
+ views.push(view);
+ await interact(async () => undefined);
+ await interact(() => button("查看引用 ›").click());
+ await interact(async () => undefined);
+ const list = document.querySelector(".channel-files-references");
+ expect(
+ vi
+ .mocked(fetch)
+ .mock.calls.some(([url]) => String(url).endsWith(`${kept.id}/references?limit=20`)),
+ ).toBe(true);
+ expect(list?.textContent).toContain("研究助理");
+ expect(list?.textContent).toContain("系统消息");
+ expect(list?.textContent).toContain("汇总渠道");
+ const buttons = list?.querySelectorAll("button") ?? [];
+ await interact(() => (buttons[0] as HTMLButtonElement).click());
+ expect(showMessage).toHaveBeenCalledWith("m-1");
+ await interact(() => (buttons[2] as HTMLButtonElement).click());
+ expect(showTask).toHaveBeenCalledWith("run-9");
+});
diff --git a/apps/web/src/components/AttachmentsManager.tsx b/apps/web/src/components/AttachmentsManager.tsx
index 05b0f662..60db9203 100644
--- a/apps/web/src/components/AttachmentsManager.tsx
+++ b/apps/web/src/components/AttachmentsManager.tsx
@@ -2,14 +2,17 @@ import type { Artifact } from "@openbot/domain";
import { useEffect, useState } from "react";
import {
AttachmentCommandError,
+ type AttachmentReferences,
cleanupChannelTrash,
downloadAttachment,
extensionOf,
formatAttachmentSize,
+ getAttachmentReferences,
purgeAttachment,
updateAttachment,
} from "../channel-attachment-client";
import type { UploadedComposerAttachment } from "../composer-context";
+import { runStatusLabel } from "../run-state";
import { ArtifactDownloadLink } from "./ArtifactCard";
import { Dialog } from "./Dialog";
import { sidebarTime } from "./Sidebar";
@@ -81,8 +84,11 @@ export function AttachmentsManagerDialog({
channelName,
outputs = [],
botNameForRun = () => undefined,
+ botName = () => undefined,
initialTab = "files",
onClose,
+ onShowMessage,
+ onShowTask,
}: {
channelId: string;
initialTab?: "files" | "trash";
@@ -90,6 +96,10 @@ export function AttachmentsManagerDialog({
/** This channel's task outputs; they can be downloaded but not moved to the 回收站. */
outputs?: Artifact[];
botNameForRun?(runId: string): string | undefined;
+ botName?: ((botId: string) => string | undefined) | undefined;
+ /** C24: open a referencing message in the conversation; without it the list is read-only. */
+ onShowMessage?: ((messageId: string) => void) | undefined;
+ onShowTask?: ((runId: string) => void) | undefined;
onClose(): void;
}) {
const [revision, setRevision] = useState(0);
@@ -100,6 +110,7 @@ export function AttachmentsManagerDialog({
const [purge, setPurge] = useState();
const [purging, setPurging] = useState(false);
const [purgeError, setPurgeError] = useState("");
+ const [referencesOpen, setReferencesOpen] = useState();
const [source, setSource] = useState("all");
const [query, setQuery] = useState("");
const [limit, setLimit] = useState(PAGE);
@@ -410,8 +421,27 @@ export function AttachmentsManagerDialog({
{isReferenced(file) ? (
{keptNote(file)}
+
) : null}
+ {referencesOpen === file.id ? (
+
+ ) : null}
))}
@@ -441,6 +471,83 @@ export function AttachmentsManagerDialog({
);
}
+function authorLabel(
+ author: AttachmentReferences["messages"][number]["author"],
+ botName: (botId: string) => string | undefined,
+): string {
+ if (author.kind === "owner") return "你";
+ if (author.kind === "system") return "系统消息";
+ return (author.botId && botName(author.botId)) || "Bot";
+}
+
+/**
+ * C24 查看引用: the messages and tasks that keep a file, newest first. Choosing one leaves the
+ * dialog for the conversation or 任务详情; the 服务电脑 sends previews only, never full content.
+ */
+function ReferenceList({
+ channelId,
+ file,
+ botName,
+ onShowMessage,
+ onShowTask,
+}: {
+ channelId: string;
+ file: UploadedComposerAttachment;
+ botName: (botId: string) => string | undefined;
+ onShowMessage?: ((messageId: string) => void) | undefined;
+ onShowTask?: ((runId: string) => void) | undefined;
+}) {
+ const [references, setReferences] = useState();
+ const [failed, setFailed] = useState(false);
+ useEffect(() => {
+ const controller = new AbortController();
+ getAttachmentReferences(channelId, file.id, controller.signal)
+ .then((value) => {
+ if (!controller.signal.aborted) setReferences(value);
+ })
+ .catch(() => {
+ if (!controller.signal.aborted) setFailed(true);
+ });
+ return () => controller.abort();
+ }, [channelId, file.id]);
+ if (failed)
+ return 没能读取引用,请稍后再试。
;
+ if (!references) return 正在读取引用…
;
+ const shown = references.messages.length + references.tasks.length;
+ const total = references.messageCount + references.taskCount;
+ return (
+
+ {references.messages.map((message) => (
+ -
+
+
+ ))}
+ {references.tasks.map((task) => (
+ -
+
+
+ ))}
+ {references.hasMore ? (
+ - 还有 {total - shown} 条更早的引用没有列出。
+ ) : null}
+
+ );
+}
+
/** ChannelFilesTrash artboard: the second confirmation before anything is permanently deleted. */
function PurgeConfirm({
files,
diff --git a/apps/web/src/components/BotInfoRail.tsx b/apps/web/src/components/BotInfoRail.tsx
index d7c0beaa..0ccac081 100644
--- a/apps/web/src/components/BotInfoRail.tsx
+++ b/apps/web/src/components/BotInfoRail.tsx
@@ -206,6 +206,7 @@ export function BotInfoRail({
)
.sort((left, right) => right.createdAt.localeCompare(left.createdAt))}
botNameForRun={() => bot.name}
+ botName={(botId) => (botId === bot.id ? bot.name : undefined)}
channelName={bot.name}
/>
) : (
diff --git a/apps/web/src/components/ChannelWorkspace.test.tsx b/apps/web/src/components/ChannelWorkspace.test.tsx
index d94590b6..3d4a3400 100644
--- a/apps/web/src/components/ChannelWorkspace.test.tsx
+++ b/apps/web/src/components/ChannelWorkspace.test.tsx
@@ -9,6 +9,7 @@ import {
listRuns,
subscribeToChannelEvents,
} from "../api";
+import { showMessageEvent } from "../composer-events";
import { createConversationSession } from "../conversation-session";
import { deferred, interact, renderComponent } from "../test/render-component";
import { ChannelWorkspace, dateCueLabel } from "./ChannelWorkspace";
@@ -996,6 +997,41 @@ describe("23e: older pages, banners and the date cue", () => {
await rendered.unmount();
});
+ it("jumps to a referenced message, reading an older page when it is not loaded", async () => {
+ const latest = Array.from({ length: 100 }, (_, index) => numbered(index + 10));
+ vi.mocked(listMessages).mockResolvedValue(latest);
+ vi.mocked(listMessagePage)
+ .mockResolvedValueOnce({ messages: latest, hasMore: true, nextCursor: "c-latest" })
+ .mockResolvedValueOnce({
+ messages: Array.from({ length: 10 }, (_, index) => numbered(index)),
+ hasMore: false,
+ });
+ const session = createConversationSession();
+ const rendered = await renderComponent(view("a", session));
+ await interact(() =>
+ window.dispatchEvent(
+ new CustomEvent(showMessageEvent, { detail: { channelId: "a", messageId: "m003" } }),
+ ),
+ );
+ // Reading the older page and scrolling take a few ticks; wait for the result, not a fixed delay.
+ await vi.waitFor(
+ async () => {
+ await interact(async () => undefined);
+ expect(document.activeElement?.id).toBe("channel-message-m003");
+ },
+ { timeout: 5_000, interval: 20 },
+ );
+ expect(listMessagePage).toHaveBeenCalledTimes(2);
+ // Another channel's request is ignored.
+ await interact(() =>
+ window.dispatchEvent(
+ new CustomEvent(showMessageEvent, { detail: { channelId: "b", messageId: "m050" } }),
+ ),
+ );
+ expect(document.activeElement?.id).toBe("channel-message-m003");
+ await rendered.unmount();
+ }, 20_000);
+
it("does not ask for older pages when the latest page already holds everything", async () => {
vi.mocked(listMessages).mockResolvedValue([numbered(1), numbered(2)]);
const session = createConversationSession();
diff --git a/apps/web/src/components/ChannelWorkspace.tsx b/apps/web/src/components/ChannelWorkspace.tsx
index 32ea29bb..038cebe8 100644
--- a/apps/web/src/components/ChannelWorkspace.tsx
+++ b/apps/web/src/components/ChannelWorkspace.tsx
@@ -62,7 +62,7 @@ import { RichMessage } from "./RichMessage";
import { useListScroll } from "./useListScroll";
import { VoiceRecorder } from "./VoiceRecorder";
import "./ChannelMessagePresentation.css";
-import { composerAttachEvent } from "../composer-events";
+import { composerAttachEvent, type ShowMessageDetail, showMessageEvent } from "../composer-events";
import { runStatusSummary } from "../run-state";
import { AppIcon } from "./AppIcon";
import { ArtifactCard } from "./ArtifactCard";
@@ -80,6 +80,9 @@ import {
} from "./RunCollaboration";
import { TaskCard } from "./TaskCard";
+/** Older pages (100 messages each) read at most when jumping to a referenced message. */
+const JUMP_PAGES = 10;
+
export function ChannelWorkspace({
headerAction,
globalHeader = false,
@@ -194,6 +197,7 @@ export function ChannelWorkspace({
const awayFrom = useRef(undefined);
const [reactions, setReactions] = useState([]);
const [filesOpen, setFilesOpen] = useState(false);
+ const [jumpNotice, setJumpNotice] = useState();
const [outputs, setOutputs] = useState>(new Map());
const [awayFromLatest, setAwayFromLatest] = useState(!conversation.scroll.atBottom);
const messageList = useRef(null);
@@ -815,6 +819,42 @@ export function ChannelWorkspace({
conversation.setHistory({ loading: false, error: "没能加载更早的消息。" });
}
}
+ /**
+ * C24 查看引用: scroll to one message, reading older pages first if it is not loaded. Bounded, so
+ * a very old reference ends with a hint instead of loading the whole history.
+ */
+ async function jumpToMessage(id: string) {
+ setJumpNotice(undefined);
+ for (let page = 0; ; page += 1) {
+ if (conversation.getSnapshot().messages.some((item) => item.id === id)) {
+ // The prepended page commits (and restores its scroll anchor) synchronously; move after it.
+ window.setTimeout(() => showMessage(id), 0);
+ return;
+ }
+ const history = conversation.getSnapshot().history;
+ if (history.exhausted || history.error || page >= JUMP_PAGES) break;
+ await loadOlder();
+ }
+ setJumpNotice(
+ conversation.getSnapshot().history.exhausted
+ ? "这条消息已经不在了。"
+ : "这条消息比较早,往上翻可以找到它。",
+ );
+ }
+ // biome-ignore lint/correctness/useExhaustiveDependencies: the handler reads the live conversation.
+ useEffect(() => {
+ const show = (event: Event) => {
+ const detail = (event as CustomEvent).detail;
+ if (detail?.channelId === channel.id) void jumpToMessage(detail.messageId);
+ };
+ window.addEventListener(showMessageEvent, show);
+ return () => window.removeEventListener(showMessageEvent, show);
+ }, [channel.id]);
+ useEffect(() => {
+ if (!jumpNotice) return;
+ const timer = window.setTimeout(() => setJumpNotice(undefined), 4000);
+ return () => window.clearTimeout(timer);
+ }, [jumpNotice]);
/** LongLists: while scrolling, the date of the topmost visible message floats at the top. */
function showDateCue(list: HTMLElement) {
window.clearTimeout(dateCueTimer.current);
@@ -866,9 +906,23 @@ export function ChannelWorkspace({
const run = runs.find((item) => item.id === runId);
return run ? botsById.get(run.botId)?.name : undefined;
}}
+ botName={(botId) => botsById.get(botId)?.name}
onClose={() => setFilesOpen(false)}
+ onShowMessage={(messageId) => {
+ setFilesOpen(false);
+ void jumpToMessage(messageId);
+ }}
+ onShowTask={(runId) => {
+ setFilesOpen(false);
+ onInspectRun(runId);
+ }}
/>
)}
+ {jumpNotice ? (
+
+ {jumpNotice}
+
+ ) : null}
botById.get(botId)?.name}
+ onInspectRun={onInspectRun}
/>
) : null}
@@ -495,11 +497,16 @@ export function ChannelLibrary({
channelName,
artifacts,
botNameForRun,
+ botName,
+ onInspectRun,
}: {
channelId: string;
channelName?: string | undefined;
artifacts: Artifact[];
botNameForRun(runId: string): string | undefined;
+ botName?: ((botId: string) => string | undefined) | undefined;
+ /** Opens 任务详情 for a referencing task; without it task references are read-only. */
+ onInspectRun?: ((runId: string) => void) | undefined;
}) {
const { files, status } = useChannelAttachments(channelId);
const [managing, setManaging] = useState(false);
@@ -586,7 +593,24 @@ export function ChannelLibrary({
channelName={channelName}
outputs={artifacts}
botNameForRun={botNameForRun}
+ botName={botName}
onClose={() => setManaging(false)}
+ onShowMessage={(messageId) => {
+ setManaging(false);
+ window.dispatchEvent(
+ new CustomEvent(showMessageEvent, {
+ detail: { channelId, messageId },
+ }),
+ );
+ }}
+ onShowTask={
+ onInspectRun
+ ? (runId) => {
+ setManaging(false);
+ onInspectRun(runId);
+ }
+ : undefined
+ }
/>
) : null}
>
diff --git a/apps/web/src/components/DesktopSettingsScreen.tsx b/apps/web/src/components/DesktopSettingsScreen.tsx
index 00d02ee5..db0f4fb3 100644
--- a/apps/web/src/components/DesktopSettingsScreen.tsx
+++ b/apps/web/src/components/DesktopSettingsScreen.tsx
@@ -513,7 +513,11 @@ export function DesktopSettingsScreen({
)}
{section === "audit" && }
- {section === "storage" && }
+ {section === "storage" && (
+
+ {(workspace) => }
+
+ )}
{section === "browser" && (
{(workspace) => }
diff --git a/apps/web/src/components/SettingsBrowser.tsx b/apps/web/src/components/SettingsBrowser.tsx
index 59cea37d..744113a5 100644
--- a/apps/web/src/components/SettingsBrowser.tsx
+++ b/apps/web/src/components/SettingsBrowser.tsx
@@ -4,6 +4,7 @@ import { ApiError, maintainEmployeeBrowser } from "../api";
import { EmployeeBrowser } from "./EmployeeBrowser";
import { RobotAvatar } from "./RobotAvatar";
import { SettingRow, SettingsGroup } from "./SettingsSections";
+import { formatStorageSize } from "./SettingsStorage";
type Operation = "status" | "restart" | "clear";
@@ -63,7 +64,11 @@ export function SettingsBrowser({ bots }: { bots: Bot[] }) {
{browserBots.map((bot) => {
const result = results[bot.id];
const state = result
- ? `${result.paused ? "已暂停" : result.running ? "运行中" : "未运行"} · 主机 ${result.nodeId}`
+ ? `${result.paused ? "已暂停" : result.running ? "运行中" : "未运行"} · 主机 ${result.nodeId} · ${
+ result.profileBytes === null
+ ? "浏览器数据量不出"
+ : `浏览器数据 ${formatStorageSize(result.profileBytes)}`
+ }`
: "点「检查状态」查看";
const working = busy?.startsWith(`${bot.id}:`) ?? false;
return (
diff --git a/apps/web/src/components/SettingsDataSections.test.tsx b/apps/web/src/components/SettingsDataSections.test.tsx
index 63943823..7f8fea4f 100644
--- a/apps/web/src/components/SettingsDataSections.test.tsx
+++ b/apps/web/src/components/SettingsDataSections.test.tsx
@@ -446,7 +446,13 @@ it("checks, restarts and (after confirmation) clears one Bot's employee browser"
const fetch = server({
[`POST /api/v1/bots/${botId}/browser/maintenance`]: (init) => {
const body = JSON.parse(String(init?.body));
- return { botId, nodeId: "docker-1", running: body.operation !== "clear", paused: false };
+ return {
+ botId,
+ nodeId: "docker-1",
+ running: body.operation !== "clear",
+ paused: false,
+ ...(body.operation === "status" ? { profileBytes: 52_000_000 } : {}),
+ };
},
});
const view = await renderComponent(
@@ -460,7 +466,7 @@ it("checks, restarts and (after confirmation) clears one Bot's employee browser"
expect(fetch).not.toHaveBeenCalled();
await interact(() => buttonNamed(view.container, "检查状态")?.click());
expect(view.container.querySelector(".settings-browser-row small")?.textContent).toBe(
- "运行中 · 主机 docker-1",
+ "运行中 · 主机 docker-1 · 浏览器数据 52.0 MB",
);
await interact(() => buttonNamed(view.container, "清除浏览数据…")?.click());
expect(view.container.querySelector('[role="alertdialog"]')).not.toBeNull();
@@ -548,3 +554,120 @@ it("formats storage in decimal units", () => {
expect(formatStorageSize(18_400_000_000)).toBe("18.4 GB");
expect(formatStorageSize(250_000_000_000)).toBe("250 GB");
});
+
+it("empties every 回收站 once and retries an unclear result with the same key", async () => {
+ const GB = 1_000_000_000;
+ const usage = {
+ totalBytes: 2 * GB,
+ measuredAt: t,
+ categories: {
+ channelFiles: { sizeBytes: GB, fileCount: 1 },
+ trash: { sizeBytes: GB, fileCount: 4 },
+ ownerTaskFiles: { sizeBytes: 0, fileCount: 0 },
+ taskOutputs: { sizeBytes: 0, fileCount: 0 },
+ retainedRunOutputs: null,
+ other: { sizeBytes: 0, fileCount: 0 },
+ database: { sizeBytes: 0 },
+ workingComputerBrowserData: null,
+ },
+ trash: { fileCount: 4, sizeBytes: GB, referencedFileCount: 1, referencedSizeBytes: 0.25 * GB },
+ topChannels: [],
+ topChannelsLimit: 20,
+ };
+ const keys: string[] = [];
+ let attempts = 0;
+ const fetch = vi.fn(async (url: string, init?: RequestInit) => {
+ if (url === "/api/v1/storage") return Response.json(usage);
+ if (url === "/api/v1/settings/storage")
+ return Response.json({
+ revision: 1,
+ trashAutoPurgeDays: null,
+ updatedAt: null,
+ lastAutoPurgeAt: null,
+ });
+ if (url === "/api/v1/storage/trash/cleanup") {
+ keys.push(JSON.parse(String(init?.body)).requestKey);
+ attempts += 1;
+ if (attempts === 1) throw new TypeError("Failed to fetch");
+ return Response.json({
+ removed: 3,
+ retained: [],
+ retainedCount: 1,
+ retainedHasMore: false,
+ freedBytes: 0.75 * GB,
+ channelCount: 2,
+ });
+ }
+ return Response.json({ error: "Unexpected request" }, { status: 500 });
+ });
+ vi.stubGlobal("fetch", fetch);
+ HTMLDialogElement.prototype.showModal = vi.fn(function (this: HTMLDialogElement) {
+ this.setAttribute("open", "");
+ });
+ HTMLDialogElement.prototype.close = vi.fn(function (this: HTMLDialogElement) {
+ this.removeAttribute("open");
+ });
+ const view = await renderComponent();
+ try {
+ await interact(() => undefined);
+ await interact(() => buttonNamed(view.container, "清空回收站…")?.click());
+ const confirm = () =>
+ view.container.querySelector(".storage-confirm .is-danger");
+ expect(view.container.querySelector(".storage-confirm")?.textContent).toContain(
+ "永久删除 3 个文件?",
+ );
+ expect(view.container.querySelector(".storage-confirm")?.textContent).toContain("共 750 MB");
+ await interact(() => confirm()?.click());
+ expect(confirm()?.textContent).toBe("重试");
+ await interact(() => confirm()?.click());
+ expect(keys).toHaveLength(2);
+ expect(keys[1]).toBe(keys[0]);
+ expect(view.container.textContent).toContain(
+ "已永久删除 3 个文件,释放 750 MB。1 个还被引用,已保留。",
+ );
+ } finally {
+ await view.unmount();
+ }
+});
+
+it("measures each Bot's browser data on request and never shows an unknown size as zero", async () => {
+ const bots = [
+ { id: "b1", name: "研究助理", computerProfile: "docker-linux" },
+ { id: "b2", name: "发布助手", computerProfile: "docker-linux" },
+ { id: "b3", name: "客服", computerProfile: "none" },
+ ] as Bot[];
+ const fetch = vi.fn(async (url: string) => {
+ if (url === "/api/v1/storage") return Response.json({ error: "x" }, { status: 503 });
+ if (url === "/api/v1/settings/storage")
+ return Response.json({
+ revision: 1,
+ trashAutoPurgeDays: null,
+ updatedAt: null,
+ lastAutoPurgeAt: null,
+ });
+ const bot = url.match(/bots\/(b\d)\/browser/)?.[1];
+ if (bot)
+ return Response.json({
+ botId: "00000000-0000-4000-8000-00000000b0b0",
+ nodeId: "n",
+ running: true,
+ paused: false,
+ profileBytes: bot === "b1" ? 186_000_000 : null,
+ });
+ return Response.json({ error: "Unexpected request" }, { status: 500 });
+ });
+ vi.stubGlobal("fetch", fetch);
+ const view = await renderComponent();
+ try {
+ await interact(() => undefined);
+ expect(fetch.mock.calls.some(([url]) => String(url).includes("/browser/"))).toBe(false);
+ await interact(() => buttonNamed(view.container, "测量")?.click());
+ const text = view.container.textContent ?? "";
+ expect(text).toContain("186 MB");
+ expect(text).toContain("1 个 Bot 的工作电脑量不出");
+ expect(text).toContain("发布助手量不出");
+ expect(fetch.mock.calls.filter(([url]) => String(url).includes("/browser/"))).toHaveLength(2);
+ } finally {
+ await view.unmount();
+ }
+});
diff --git a/apps/web/src/components/SettingsStorage.css b/apps/web/src/components/SettingsStorage.css
index 4bb6dee2..53184949 100644
--- a/apps/web/src/components/SettingsStorage.css
+++ b/apps/web/src/components/SettingsStorage.css
@@ -96,3 +96,26 @@
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
+/* SettingsStorage: 清空回收站… is tinted red; its confirmation follows ChannelFilesTrash. */
+.settings-dialog .setting-control .ob-pill.storage-danger,
+.ob-pill.storage-danger {
+ background: #fbe9e7;
+ color: #b3261e;
+}
+.storage-confirm .ob-dialog-sheet {
+ gap: 12px;
+ padding: 22px 24px 18px;
+}
+.storage-confirm .ob-dialog-header h2 {
+ font-size: 17px;
+}
+.storage-confirm-text {
+ margin: 0;
+ color: #3a3a3c;
+ font-size: 14px;
+ line-height: 1.55;
+}
+.storage-confirm-kept {
+ color: #8e8e93;
+ font-size: 12px;
+}
diff --git a/apps/web/src/components/SettingsStorage.tsx b/apps/web/src/components/SettingsStorage.tsx
index 538b2917..c89a08d5 100644
--- a/apps/web/src/components/SettingsStorage.tsx
+++ b/apps/web/src/components/SettingsStorage.tsx
@@ -1,14 +1,18 @@
+import type { Bot } from "@openbot/domain";
import { useEffect, useState } from "react";
import {
ApiError,
+ cleanupAllTrash,
getStorageSettings,
getStorageUsage,
+ maintainEmployeeBrowser,
type StorageCategory,
type StorageSettings,
type StorageUsage,
updateStorageSettings,
} from "../api";
import { AttachmentsManagerDialog } from "./AttachmentsManager";
+import { Dialog } from "./Dialog";
import { SettingRow, SettingsGroup } from "./SettingsSections";
import { sidebarTime } from "./Sidebar";
import "./SettingsStorage.css";
@@ -65,11 +69,12 @@ export function storageCategories(usage: StorageUsage): Array<{
}
/**
- * 设置 › 存储空间 (SettingsStorage artboard; backlog C21). The 服务电脑 measures what it stores
- * and refuses rather than estimates; only the 回收站 can be cleaned, per channel, from 频道文件.
- * Browser profiles live on the working computers and are not measured here.
+ * 设置 › 存储空间 (SettingsStorage artboard; C21, C22, C23). The 服务电脑 measures what it stores
+ * and refuses rather than estimates; only the 回收站 can be cleaned, all at once or per channel.
+ * Browser profiles live on the working computers: they are measured on request, Bot by Bot, and
+ * never added to the 服务电脑 total.
*/
-export function SettingsStorage() {
+export function SettingsStorage({ bots = [] }: { bots?: Bot[] }) {
const [usage, setUsage] = useState();
const [usageError, setUsageError] = useState("");
const [settings, setSettings] = useState();
@@ -77,6 +82,10 @@ export function SettingsStorage() {
const [saving, setSaving] = useState(false);
const [reload, setReload] = useState(0);
const [viewing, setViewing] = useState<{ id: string; name: string }>();
+ const [cleanup, setCleanup] = useState<{ requestKey: string; unclear?: boolean }>();
+ const [cleaning, setCleaning] = useState(false);
+ const [cleanupError, setCleanupError] = useState("");
+ const [notice, setNotice] = useState("");
// biome-ignore lint/correctness/useExhaustiveDependencies: `reload` re-measures after a change.
useEffect(() => {
@@ -123,9 +132,42 @@ export function SettingsStorage() {
}
}
+ async function emptyTrash(target: { requestKey: string }) {
+ setCleaning(true);
+ setCleanupError("");
+ try {
+ const result = await cleanupAllTrash(target.requestKey);
+ setNotice(
+ `已永久删除 ${result.removed} 个文件,释放 ${formatStorageSize(result.freedBytes)}。${
+ result.retainedCount > 0 ? `${result.retainedCount} 个还被引用,已保留。` : ""
+ }`,
+ );
+ setCleanup(undefined);
+ setReload((value) => value + 1);
+ } catch (cause) {
+ if (!(cause instanceof ApiError)) {
+ // The same key replays the saved result, so 重试 cannot delete twice.
+ setCleanup({ ...target, unclear: true });
+ setCleanupError("没能确认是否已经删除。点「重试」会接着同一次清理,不会重复删除。");
+ return;
+ }
+ setCleanup(undefined);
+ setNotice(
+ cause.status === 404
+ ? "这台服务电脑还不能一次清空,请到各频道的「频道文件 › 回收站」里清空。"
+ : "没能清空回收站,已重新测量。",
+ );
+ setReload((value) => value + 1);
+ } finally {
+ setCleaning(false);
+ }
+ }
+
const categories = usage ? storageCategories(usage) : [];
const total = usage?.totalBytes ?? 0;
const trash = usage?.trash;
+ const removable = trash ? trash.fileCount - trash.referencedFileCount : 0;
+ const dockerBots = bots.filter((bot) => bot.computerProfile === "docker-linux");
const autoPurge = settings?.trashAutoPurgeDays === 30;
return (
@@ -179,6 +221,11 @@ export function SettingsStorage() {
) : null}
+ {notice ? (
+
+ {notice}
+
+ ) : null}
{trash ? (
0
- ? `其中 ${trash.referencedFileCount} 个还被消息或任务引用,会保留;其余 ${trash.fileCount - trash.referencedFileCount} 个可以在各频道的「频道文件 › 回收站」里永久删除。`
- : "都可以在各频道的「频道文件 › 回收站」里永久删除。"
+ ? `其中 ${trash.referencedFileCount} 个还被消息或任务引用,会保留;其余 ${removable} 个可以永久删除。`
+ : "都可以永久删除。"
}
- />
+ >
+
+
) : null}
+ {dockerBots.length > 0 ? (
+
+
+
+ ) : null}
+
{usage && usage.topChannels.length > 0 ? (
{usage.topChannels.map((channel) => (
@@ -241,6 +306,56 @@ export function SettingsStorage() {
浏览器的登录数据保存在工作电脑上,在「设置 › 员工浏览器」里清除。
+ {cleanup && trash ? (
+
+ ) : null}
+
{viewing ? (
);
}
+
+/**
+ * C23: each Bot's browser profile on its working computer, read through browser status only when
+ * asked. A Bot whose computer cannot measure it shows 「量不出」, never zero.
+ */
+function BrowserProfiles({ bots }: { bots: Bot[] }) {
+ const [sizes, setSizes] = useState>();
+ const [measuring, setMeasuring] = useState(false);
+ async function measure() {
+ setMeasuring(true);
+ const next: Record = {};
+ for (const bot of bots) {
+ try {
+ next[bot.id] = (await maintainEmployeeBrowser(bot.id, "status")).profileBytes;
+ } catch {
+ next[bot.id] = null;
+ }
+ }
+ setSizes(next);
+ setMeasuring(false);
+ }
+ const known = sizes
+ ? Object.values(sizes).filter((value): value is number => value !== null)
+ : [];
+ const unknown = sizes ? bots.length - known.length : 0;
+ return (
+ <>
+ 0
+ ? formatStorageSize(known.reduce((sum, value) => sum + value, 0))
+ : `${bots.length} 个 Bot 使用员工浏览器`
+ }
+ description={
+ sizes
+ ? unknown > 0
+ ? `${unknown} 个 Bot 的工作电脑量不出。不计入上面服务电脑的总量。`
+ : "不计入上面服务电脑的总量。"
+ : "保存在各自的工作电脑上,不计入上面的总量。需要时再测量。"
+ }
+ >
+
+
+ {sizes
+ ? bots.map((bot) => (
+
+ ))
+ : null}
+ >
+ );
+}
diff --git a/apps/web/src/composer-events.ts b/apps/web/src/composer-events.ts
index 782dc4af..b7bc729e 100644
--- a/apps/web/src/composer-events.ts
+++ b/apps/web/src/composer-events.ts
@@ -3,3 +3,13 @@
* so the picker keeps the user activation; uploads still go through the composer's checks.
*/
export const composerAttachEvent = "openbot:composer-attach";
+
+/**
+ * Asks the open conversation to show one message (C24 查看引用). Detail: `{channelId, messageId}`;
+ * a conversation of another channel ignores it.
+ */
+export const showMessageEvent = "openbot:show-message";
+export interface ShowMessageDetail {
+ channelId: string;
+ messageId: string;
+}
diff --git a/apps/web/src/preview/scenes.tsx b/apps/web/src/preview/scenes.tsx
index d29ee1cf..c9ff70c6 100644
--- a/apps/web/src/preview/scenes.tsx
+++ b/apps/web/src/preview/scenes.tsx
@@ -94,6 +94,7 @@ const components: Record ReactElement> = {
channelId="c-market"
channelName="市场周报"
initialTab="trash"
+ botName={(botId) => world.bots.find((item) => item.id === botId)?.name}
onClose={close}
/>
),
diff --git a/apps/web/src/preview/transport.ts b/apps/web/src/preview/transport.ts
index 89753362..538bea61 100644
--- a/apps/web/src/preview/transport.ts
+++ b/apps/web/src/preview/transport.ts
@@ -245,7 +245,12 @@ export function createPreviewFetch(origin: string, world: PreviewWorld = createW
trash: { sizeBytes: 1.2 * GB, fileCount: 12 },
workingComputerBrowserData: null,
},
- trash: { fileCount: 12, sizeBytes: 1.2 * GB, referencedFileCount: 3 },
+ trash: {
+ fileCount: 12,
+ sizeBytes: 1.2 * GB,
+ referencedFileCount: 3,
+ referencedSizeBytes: 0.3 * GB,
+ },
topChannels: [
{
id: "c-market",
@@ -273,6 +278,64 @@ export function createPreviewFetch(origin: string, world: PreviewWorld = createW
});
},
],
+ [
+ "POST",
+ /^\/api\/v1\/storage\/trash\/cleanup$/,
+ () =>
+ json({
+ removed: 9,
+ retained: [],
+ retainedCount: 3,
+ retainedHasMore: false,
+ freedBytes: 900_000_000,
+ channelCount: 4,
+ }),
+ ],
+ [
+ "GET",
+ /^\/api\/v1\/channels\/([^/]+)\/attachments\/([^/]+)\/references$/,
+ () =>
+ json({
+ messages: [
+ {
+ id: "m-ref-1",
+ createdAt: "2026-09-18T02:10:00.000Z",
+ author: { kind: "owner" },
+ preview: "这是上个月的渠道数据。",
+ },
+ {
+ id: "m-ref-2",
+ createdAt: "2026-09-17T08:30:00.000Z",
+ author: { kind: "bot", botId: "b-research" },
+ preview: "我按渠道把 8 月的数据整理成了表格。",
+ },
+ {
+ id: "m-ref-3",
+ createdAt: "2026-09-16T01:00:00.000Z",
+ author: { kind: "system" },
+ preview: "例行任务「每周渠道汇总」已开始。",
+ },
+ ],
+ tasks: [],
+ messageCount: 3,
+ taskCount: 0,
+ hasMore: false,
+ }),
+ ],
+ [
+ "POST",
+ /^\/api\/v1\/bots\/([^/]+)\/browser\/maintenance$/,
+ (m) =>
+ json({
+ // The protocol requires a UUID; the preview's readable Bot ids are mapped to one.
+ botId: "00000000-0000-4000-8000-00000000b0b0",
+ nodeId: "n-1",
+ running: true,
+ paused: false,
+ // The deployed upstream cannot measure yet for one Bot: shown as 「量不出」.
+ profileBytes: m[1] === "b-research" ? 186_000_000 : null,
+ }),
+ ],
["GET", /^\/api\/v1\/settings\/storage$/, () => json(storageSettings)],
[
"PUT",
diff --git a/apps/web/src/preview/world.ts b/apps/web/src/preview/world.ts
index ce002083..e9ebba8a 100644
--- a/apps/web/src/preview/world.ts
+++ b/apps/web/src/preview/world.ts
@@ -85,9 +85,16 @@ export function createWorld(kind: "full" | "empty" | "new-bot" | "long" = "full"
return world;
}
const bots = [
- bot("b-research", "研究助理", "信息 · 竞品研究", "round", "green", "running"),
+ // Two Bots use the 员工浏览器, so 设置 › 员工浏览器 and 存储空间 have browser data to show.
+ {
+ ...bot("b-research", "研究助理", "信息 · 竞品研究", "round", "green", "running"),
+ computerProfile: "docker-linux",
+ },
bot("b-cs", "客服小橙", "客服 · 工单", "cat", "yellow"),
- bot("b-release", "发布助手", "开发 · 发布说明", "square", "blue"),
+ {
+ ...bot("b-release", "发布助手", "开发 · 发布说明", "square", "blue"),
+ computerProfile: "docker-linux",
+ },
bot("b-design", "设计评审", "设计 · 走查", "square", "red"),
bot("b-ops", "运维值班", "运维 · 巡检", "round", "blue", "offline"),
];
diff --git a/docs/design/desktop-ui-2026-10/DESIGN.md b/docs/design/desktop-ui-2026-10/DESIGN.md
index 9c46fcee..7550e7d7 100644
--- a/docs/design/desktop-ui-2026-10/DESIGN.md
+++ b/docs/design/desktop-ui-2026-10/DESIGN.md
@@ -169,7 +169,7 @@ the source; the `.dc.html` snapshot beside this file is its read-only copy (see
| TaskCards, TaskInspector | `TaskCard.tsx`, `TaskSheet.tsx` | Built; cards show the 服务电脑's step count (C13) |
| Dialog* | `Dialog.tsx` frame; Share, Export, Import, DeleteIdentity, NodeManager and ModelConnections dialogs | Built; 连接模型服务 edits one connection with the free model-list check, a default model and 断开 (C17) |
| WorkSupervision, EmptyWorkspace | `WorkTasksScreen.tsx`, `EmptyWorkspace.tsx` | Built; 任务监督 names the current step from the latest durable Work action |
-| ChannelFilesTrash, SettingsStorage | `AttachmentsManager.tsx`, `MessageAttachments.tsx`, `SettingsStorage.tsx` | Built (C21). The 服务电脑 cleans the 回收站 per channel, so 存储空间 links to each channel instead of a global 清空回收站…; browser data is not measured, so it is not a category; references are counts only, so there is no 查看引用. The fixes are C22–C24 ([research](../../research/storage-cleanup-follow-ups.md)) |
+| ChannelFilesTrash, SettingsStorage | `AttachmentsManager.tsx`, `MessageAttachments.tsx`, `SettingsStorage.tsx`, `SettingsBrowser.tsx` | Built (C21–C24). 存储空间 offers 清空回收站… for every channel, measures each Bot's browser data on request (shown apart from the 服务电脑 total; 「量不出」 when the working computer cannot measure), and 查看引用 › lists the referencing messages and tasks |
## Designed in step 22 (approved 2026-10-02)
diff --git a/docs/design/desktop-ui-2026-10/DESIGN.zh-CN.md b/docs/design/desktop-ui-2026-10/DESIGN.zh-CN.md
index ecdb7cfd..7219b116 100644
--- a/docs/design/desktop-ui-2026-10/DESIGN.zh-CN.md
+++ b/docs/design/desktop-ui-2026-10/DESIGN.zh-CN.md
@@ -145,7 +145,7 @@
| TaskCards、TaskInspector | `TaskCard.tsx`、`TaskSheet.tsx` | 已完成;任务卡显示服务电脑报告的步数(C13) |
| Dialog* | `Dialog.tsx` 框架;分享、分享 Bot 模板、导入、删除、配对工作电脑、连接模型服务 | 已完成;连接模型服务一次编辑一个连接:免费读取模型列表、默认模型、断开(C17) |
| WorkSupervision、EmptyWorkspace | `WorkTasksScreen.tsx`、`EmptyWorkspace.tsx` | 已完成;任务监督按最新的 Work 动作显示当前步骤 |
-| ChannelFilesTrash、SettingsStorage | `AttachmentsManager.tsx`、`MessageAttachments.tsx`、`SettingsStorage.tsx` | 已完成(C21)。服务电脑按频道清空回收站,所以存储空间改为链接到各频道,不放全局的「清空回收站…」;浏览器数据量不到,所以不列这一类;引用只有次数,所以没有「查看引用」。修复见 C22–C24([研究记录](../../research/storage-cleanup-follow-ups.zh-CN.md)) |
+| ChannelFilesTrash、SettingsStorage | `AttachmentsManager.tsx`、`MessageAttachments.tsx`、`SettingsStorage.tsx`、`SettingsBrowser.tsx` | 已完成(C21–C24)。存储空间可以一次「清空回收站…」;需要时按 Bot 测量浏览器数据(不计入服务电脑总量,量不出时显示「量不出」);「查看引用 ›」列出引用文件的消息和任务 |
## 第 22 步设计(2026-10-02 已通过)
diff --git a/docs/design/desktop-ui-2026-10/IMPLEMENTATION.md b/docs/design/desktop-ui-2026-10/IMPLEMENTATION.md
index 525374ee..edc4c57f 100644
--- a/docs/design/desktop-ui-2026-10/IMPLEMENTATION.md
+++ b/docs/design/desktop-ui-2026-10/IMPLEMENTATION.md
@@ -77,7 +77,8 @@ updated.
| 25 | Storage and cleanup — **merged** | ChannelFilesTrash, SettingsStorage | C21 | 永久删除 and 清空回收站 in 频道文件 with a second confirmation, referenced files kept, an unclear cleanup retried with the same request key; 「附件已永久删除」 in messages; 设置 › 存储空间 with measured categories, the 回收站, the opt-in 30-day purge and the largest channels |
| 26 | Stage names and 任务监督 step counts — **merged** | TaskCards, TaskInspector, WorkSupervision | C13 | Chinese names for the 服务电脑's stage keys; 「第 N 步:…」 from the latest Work action |
| 27 | Retire the legacy style layer — **merged** | — | — | The seven legacy sheets are gone: component rules beside their components, shared ones in `base.css` and `shell.css`, verified by a 68-state computed-style sweep |
-| 28 | README and interface pictures — **in review** | Avatars, AppIcon | C10 | New README banner (the Round head and a pixel wordmark, drawn by `?scene=banner`), a current channel picture, the avatar sheet, and `INTERFACE.md` §5 rewritten for the head-and-jaw identity |
+| 28 | README and interface pictures — **merged** | Avatars, AppIcon | C10 | New README banner (the Round head and a pixel wordmark, drawn by `?scene=banner`), a current channel picture, the avatar sheet, and `INTERFACE.md` §5 rewritten for the head-and-jaw identity |
+| 29 | Storage follow-ups — **in review** | ChannelFilesTrash, SettingsStorage | C22, C23, C24 | 清空回收站… in 存储空间 with the second confirmation and same-key retry; per-Bot browser data measured on request; 查看引用 › jumps to the message (reading older pages, bounded) or opens 任务详情 |
Steps run in order; 15 and 20 can run in parallel with Codex items. Each step is one pull request.
@@ -96,7 +97,7 @@ Done (merged into main 40cb3d2):
| C7 | Owner time zone and default model for new Bots | Integrated |
| C8 | Curated plugin catalog | Integrated |
-Done 2026-10-02 and 2026-10-03 (#147, #149, #150, #157–#161, #164, #165):
+Done 2026-10-02 and 2026-10-03 (#147, #149, #150, #157–#161, #164, #165) and C22–C24 (Codex branch `codex/c22-c24-storage-follow-ups`):
| ID | Contract | UI |
| --- | --- | --- |
@@ -111,6 +112,9 @@ Done 2026-10-02 and 2026-10-03 (#147, #149, #150, #157–#161, #164, #165):
| C19 | File reference counts per channel attachment | Integrated (#163) |
| C20 | On-screen claims confirmed or corrected | Wording corrected (#163) |
| C21 | Permanent 回收站 deletion, a cleanup command, measured storage and an opt-in 30-day purge | Integrated (step 25) |
+| C22 | Global 清空回收站 with one replayable key, and `referencedSizeBytes` | Integrated (step 29) |
+| C23 | Browser profile bytes per Bot from browser status (upstream `agent-computer` contribution pending, so the deployed pin reports 「量不出」) | Integrated (step 29) |
+| C24 | Owner-only list of the messages and tasks that reference a file | Integrated (step 29) |
Open — in the order the UI needs them:
@@ -118,9 +122,6 @@ Open — in the order the UI needs them:
| --- | --- | --- | --- |
| C9 | Step 17 | **Edit a Bot's appearance after creation**: Owner-only, audited, revision-checked | Pencil button hidden |
| C11 | Step 16 (optional) | **New-Bot greeting**: when a model is configured, the 服务电脑 writes a short greeting as the Bot's first message, using only other Bots' names and tags; no model or any failure means no greeting | The setup card shows without a greeting |
-| C22 | Step 25 follow-up | **Global 清空回收站**: one confirmed cleanup across every active channel with one replayable `requestKey`, plus `referencedSizeBytes` in `/storage` ([research](../../research/storage-cleanup-follow-ups.md)) | 存储空间 links to each channel's 回收站 |
-| C23 | Step 25 follow-up | **Browser profile size per Bot**, measured on the working computer by `agent-computer` (upstream contribution first) and returned by browser status as `profileBytes` | No 浏览器数据 figure |
-| C24 | Step 25 follow-up | **Which messages and tasks reference a file**: an Owner-only, bounded list with a short preview | Counts only, no 查看引用 |
## Repository housekeeping (proposals)
diff --git a/docs/design/desktop-ui-2026-10/IMPLEMENTATION.zh-CN.md b/docs/design/desktop-ui-2026-10/IMPLEMENTATION.zh-CN.md
index d8f844c2..e135c957 100644
--- a/docs/design/desktop-ui-2026-10/IMPLEMENTATION.zh-CN.md
+++ b/docs/design/desktop-ui-2026-10/IMPLEMENTATION.zh-CN.md
@@ -71,7 +71,8 @@
| 25 | 存储与清理——**已合并** | ChannelFilesTrash、SettingsStorage | C21 | 频道文件里的「永久删除」和「清空回收站」,都要再确认一次;被引用的文件保留;清理结果不明时用同一个请求标识重试;消息里显示「附件已永久删除」;设置 › 存储空间:实测的分类、回收站、可选的 30 天自动清理、占用最多的频道 |
| 26 | 阶段名称与任务监督的步数——**已合并** | TaskCards、TaskInspector、WorkSupervision | C13 | 服务电脑的阶段名显示为中文;按最新的 Work 动作显示「第 N 步:…」 |
| 27 | 退役旧样式层——**已合并** | — | — | 删除 7 个旧样式表:组件规则放到各自组件旁,共用规则放进 `base.css` 和 `shell.css`;用 68 个状态的计算样式扫描验证 |
-| 28 | README 与界面说明的图片——**审核中** | Avatars、AppIcon | C10 | 新的 README 横幅(圆顶头像加像素字标,由 `?scene=banner` 绘制)、当前的频道示意图、头像图;`INTERFACE.md` 第 5 节按头型加下颌颜色重写 |
+| 28 | README 与界面说明的图片——**已合并** | Avatars、AppIcon | C10 | 新的 README 横幅(圆顶头像加像素字标,由 `?scene=banner` 绘制)、当前的频道示意图、头像图;`INTERFACE.md` 第 5 节按头型加下颌颜色重写 |
+| 29 | 存储清理的后续——**审核中** | ChannelFilesTrash、SettingsStorage | C22、C23、C24 | 存储空间里的「清空回收站…」,要再确认一次,结果不明时用同一标识重试;需要时按 Bot 测量浏览器数据;「查看引用 ›」跳到对应消息(必要时往前读有限页数)或打开任务详情 |
按顺序进行;第 15 步和第 20 步可以与 Codex 的事项并行。每一步一个拉取请求。
@@ -90,7 +91,7 @@
| C7 | Owner 时区与新 Bot 的默认模型 | 已接入 |
| C8 | 精选插件目录 | 已接入 |
-2026-10-02 与 2026-10-03 已完成(#147、#149、#150、#157–#161、#164、#165):
+2026-10-02 与 2026-10-03 已完成(#147、#149、#150、#157–#161、#164、#165),以及 C22–C24(Codex 分支 `codex/c22-c24-storage-follow-ups`):
| 编号 | 约定 | 界面 |
| --- | --- | --- |
@@ -105,6 +106,9 @@
| C19 | 每个频道附件被引用的次数 | 已接入(#163) |
| C20 | 核实或更正界面上的承诺 | 文案已更正(#163) |
| C21 | 回收站永久删除、清理命令、存储用量统计、可选的 30 天自动清理 | 已接入(第 25 步) |
+| C22 | 一次清空所有回收站(可重放的请求标识),以及 `referencedSizeBytes` | 已接入(第 29 步) |
+| C23 | 通过浏览器状态读取每个 Bot 的浏览器资料大小(上游 `agent-computer` 的改动还在审,当前部署版本显示「量不出」) | 已接入(第 29 步) |
+| C24 | 仅 Owner 可读的引用列表:哪些消息和任务引用了这个文件 | 已接入(第 29 步) |
待做——按界面需要的先后排列:
@@ -112,9 +116,6 @@
| --- | --- | --- | --- |
| C9 | 第 17 步 | **创建后修改 Bot 外观**:仅 Owner、记审计、带版本检查 | 隐藏铅笔按钮 |
| C11 | 第 16 步(可选) | **新 Bot 开场白**:配置了模型时,由服务电脑写一句简短开场白作为 Bot 的第一条消息,只使用其他 Bot 的名字和标签;没有模型或任何失败就不写 | 只显示分工卡片,没有开场白 |
-| C22 | 第 25 步后续 | **全局清空回收站**:确认一次就清理所有活跃频道,用一个可重放的 `requestKey`;`/storage` 增加 `referencedSizeBytes`([研究记录](../../research/storage-cleanup-follow-ups.zh-CN.md)) | 存储空间链接到各频道的回收站 |
-| C23 | 第 25 步后续 | **每个 Bot 的浏览器资料大小**:由 `agent-computer` 在工作电脑上测量(先向上游提交),浏览器状态返回 `profileBytes` | 不显示浏览器数据 |
-| C24 | 第 25 步后续 | **哪些消息和任务引用了这个文件**:仅 Owner、有上限的列表,带简短预览 | 只显示次数,没有「查看引用」 |
## 仓库整理(建议)