From d73a4e34bd1f5cc47a494c7bf5fd210b7b5b2b20 Mon Sep 17 00:00:00 2001 From: Wen Date: Sat, 3 Oct 2026 16:40:17 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(web):=20global=20=E6=B8=85=E7=A9=BA?= =?UTF-8?q?=E5=9B=9E=E6=94=B6=E7=AB=99,=20browser=20data=20size=20and=20?= =?UTF-8?q?=E6=9F=A5=E7=9C=8B=E5=BC=95=E7=94=A8=20(29)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - C22: 设置 › 存储空间 offers 清空回收站… for every channel after a second confirmation with the exact size; an unclear result is retried with the same request key. - C23: browser status shows each Bot's profile size; 存储空间 measures them on request, apart from the 服务电脑 total, and shows 「量不出」 rather than zero when a working computer cannot measure. - C24: 查看引用 › on a kept 回收站 file lists the referencing messages and tasks; a message opens in the conversation (reading older pages, bounded) and a task opens 任务详情. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/api.ts | 34 ++- .../web/src/channel-attachment-client.test.ts | 21 ++ apps/web/src/channel-attachment-client.ts | 37 ++++ .../web/src/components/AttachmentsManager.css | 58 ++++++ .../components/AttachmentsManager.test.tsx | 70 +++++++ .../web/src/components/AttachmentsManager.tsx | 107 ++++++++++ apps/web/src/components/BotInfoRail.tsx | 1 + .../src/components/ChannelWorkspace.test.tsx | 32 +++ apps/web/src/components/ChannelWorkspace.tsx | 56 ++++- apps/web/src/components/ContextRail.tsx | 26 ++- .../src/components/DesktopSettingsScreen.tsx | 6 +- apps/web/src/components/SettingsBrowser.tsx | 7 +- .../components/SettingsDataSections.test.tsx | 127 +++++++++++- apps/web/src/components/SettingsStorage.css | 23 +++ apps/web/src/components/SettingsStorage.tsx | 195 +++++++++++++++++- apps/web/src/composer-events.ts | 10 + apps/web/src/preview/scenes.tsx | 1 + apps/web/src/preview/transport.ts | 65 +++++- apps/web/src/preview/world.ts | 11 +- docs/design/desktop-ui-2026-10/DESIGN.md | 2 +- .../design/desktop-ui-2026-10/DESIGN.zh-CN.md | 2 +- .../desktop-ui-2026-10/IMPLEMENTATION.md | 9 +- .../IMPLEMENTATION.zh-CN.md | 9 +- 23 files changed, 882 insertions(+), 27 deletions(-) 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..1baab2db 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,37 @@ 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" } }), + ), + ); + await interact(async () => { + await new Promise((resolve) => setTimeout(resolve, 50)); + }); + expect(listMessagePage).toHaveBeenCalledTimes(2); + expect(document.activeElement?.id).toBe("channel-message-m003"); + // 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 ? ( + undefined : () => setCleanup(undefined)} + footer={ + <> + + + + } + > +

+ 所有频道回收站里没有被引用的文件 + {trash.referencedSizeBytes !== undefined + ? `,共 ${formatStorageSize(trash.sizeBytes - trash.referencedSizeBytes)}` + : ""} + 。删除后不能恢复,会记入审计。 +

+ {trash.referencedFileCount > 0 ? ( + + {trash.referencedFileCount} 个还被消息或任务引用的文件会保留。 + + ) : null} + {cleanupError ? ( +

+ {cleanupError} +

+ ) : null} +
+ ) : 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 23e5fe42..0006702e 100644 --- a/apps/web/src/preview/scenes.tsx +++ b/apps/web/src/preview/scenes.tsx @@ -93,6 +93,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 66a8109b..999c41d1 100644 --- a/docs/design/desktop-ui-2026-10/IMPLEMENTATION.md +++ b/docs/design/desktop-ui-2026-10/IMPLEMENTATION.md @@ -77,6 +77,7 @@ 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 — **in review** | — | — | 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 | +| 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. @@ -95,7 +96,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 | | --- | --- | --- | @@ -110,6 +111,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: @@ -117,9 +121,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 cdb44544..630f3c77 100644 --- a/docs/design/desktop-ui-2026-10/IMPLEMENTATION.zh-CN.md +++ b/docs/design/desktop-ui-2026-10/IMPLEMENTATION.zh-CN.md @@ -71,6 +71,7 @@ | 25 | 存储与清理——**已合并** | ChannelFilesTrash、SettingsStorage | C21 | 频道文件里的「永久删除」和「清空回收站」,都要再确认一次;被引用的文件保留;清理结果不明时用同一个请求标识重试;消息里显示「附件已永久删除」;设置 › 存储空间:实测的分类、回收站、可选的 30 天自动清理、占用最多的频道 | | 26 | 阶段名称与任务监督的步数——**已合并** | TaskCards、TaskInspector、WorkSupervision | C13 | 服务电脑的阶段名显示为中文;按最新的 Work 动作显示「第 N 步:…」 | | 27 | 退役旧样式层——**审核中** | — | — | 删除 7 个旧样式表:组件规则放到各自组件旁,共用规则放进 `base.css` 和 `shell.css`;用 68 个状态的计算样式扫描验证 | +| 29 | 存储清理的后续——**审核中** | ChannelFilesTrash、SettingsStorage | C22、C23、C24 | 存储空间里的「清空回收站…」,要再确认一次,结果不明时用同一标识重试;需要时按 Bot 测量浏览器数据;「查看引用 ›」跳到对应消息(必要时往前读有限页数)或打开任务详情 | 按顺序进行;第 15 步和第 20 步可以与 Codex 的事项并行。每一步一个拉取请求。 @@ -89,7 +90,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`): | 编号 | 约定 | 界面 | | --- | --- | --- | @@ -104,6 +105,9 @@ | C19 | 每个频道附件被引用的次数 | 已接入(#163) | | C20 | 核实或更正界面上的承诺 | 文案已更正(#163) | | C21 | 回收站永久删除、清理命令、存储用量统计、可选的 30 天自动清理 | 已接入(第 25 步) | +| C22 | 一次清空所有回收站(可重放的请求标识),以及 `referencedSizeBytes` | 已接入(第 29 步) | +| C23 | 通过浏览器状态读取每个 Bot 的浏览器资料大小(上游 `agent-computer` 的改动还在审,当前部署版本显示「量不出」) | 已接入(第 29 步) | +| C24 | 仅 Owner 可读的引用列表:哪些消息和任务引用了这个文件 | 已接入(第 29 步) | 待做——按界面需要的先后排列: @@ -111,9 +115,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、有上限的列表,带简短预览 | 只显示次数,没有「查看引用」 | ## 仓库整理(建议) From 878b8cb8c5bbf7c5a6ada22f299f7e0cbd9edd8f Mon Sep 17 00:00:00 2001 From: Wen Date: Sat, 3 Oct 2026 18:19:22 +0800 Subject: [PATCH 2/2] test(web): wait for the jump result instead of a fixed 50 ms Reading the older page took longer on hosted CI (1.4 s run), so the fixed delay checked focus too early. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/ChannelWorkspace.test.tsx | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/ChannelWorkspace.test.tsx b/apps/web/src/components/ChannelWorkspace.test.tsx index 1baab2db..3d4a3400 100644 --- a/apps/web/src/components/ChannelWorkspace.test.tsx +++ b/apps/web/src/components/ChannelWorkspace.test.tsx @@ -1013,11 +1013,15 @@ describe("23e: older pages, banners and the date cue", () => { new CustomEvent(showMessageEvent, { detail: { channelId: "a", messageId: "m003" } }), ), ); - await interact(async () => { - await new Promise((resolve) => setTimeout(resolve, 50)); - }); + // 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); - expect(document.activeElement?.id).toBe("channel-message-m003"); // Another channel's request is ignored. await interact(() => window.dispatchEvent(