refactor(web): persist the workbench from one hook

Session, project, and deliverable caches were written from scattered effects, and the legacy message migration ran twice on mount. One hook keeps that write path in a single place.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-09-19 11:01:44 +00:00
co-authored by Cursor
parent 63b0f72081
commit bfe8cb1407
4 changed files with 102 additions and 51 deletions
@@ -46,7 +46,7 @@ updated: 2026-09-19
| FE 缓存 | done(版本字段)。窗口缓存写入 `{schema,data}`,无 schema 的旧载荷丢弃。`window cache: 无 schema` |
| FE lucide | 关闭理由:`package-lock.json` 锁定 `lucide-react@1.42.0`,来源 `registry.npmjs.org`,带 integrity。`^1.16.0` 是合法的 1.x 范围,不是来源不明的包 |
| M7 | done(出站意图 + 同键重放)。`document_content_projections` 与账本同一事务写入;投影成功才销账。`TestDocRevision_PatchOrder_RetryProjectsWithoutSecondLedgerCommit`。账本库与正文库仍不是一个 SQL 事务,进程退出后靠未投影行重放,不靠客户端记忆 |
| FE App.tsx | 部分。登录壳不再静态引用导出引擎、文件预览、管理台、块编辑器和图表。入口包 2.83 MB → 1.00 MB(br 626 KB → 235 KB)。`loginShellImports.test.ts`。工作台主体仍在 `App.tsx`,未按 hook 拆文件 |
| FE App.tsx | 部分。登录壳不再静态引用重模块。本地缓存写入收到 `useWorkbenchPersistence`(含一次旧消息键迁移)。工作台主体仍在 `App.tsx`,其余 effect 未拆完 |
| H1-del | 关闭理由:`allowLegacyTestPrincipal` 仍被 `AllowInMemory && ContextDbHost==""` 的 dev/test 内存测试使用。删除会把这批测试变成未登录 401。保留告警,删除另开、先改测试夹具 |
仍开放:rdev P3 三样本真 PI、首次加载 U1/U6/U7、dev 容器重建与治理库 PG。公式引擎已离开入口包(`index-j0BIrjIR.js` 737.38 kB,br 170,451 B,包内无 katex),U3 仍缺浏览器瀑布,不关。P4 键名空间化已在本分支落地:新清单用带前缀的任务键,旧键与旧 doc_id 仍可读,`TestPublishedTaskKeysAreNamespacedAndDisjoint`。意图短语仍输出旧键,执行路径按别名接上。P2 规划界面已折叠长阶段、可批量纳入人工审批,并在选中 rdev 时提示 CDO 前置。U4 登录扇出收口仍有效,浏览器请求次数证据还没有。2026-09-19 在 `fix/audit-remainder-closeout` 跑过 `cd web && npm test && npm run build`:node:test 414 pass / 0 fail,vitest 32 pass;入口 `index-BFf6n8IA.js` 1,005.01 kB,br 235,285 B。CURRENT 文首 schema 52 与三套容器一致,00053 要等重建后才升。H2 批注/审计的项目成员检查在 Postgres 上走 `RequireProjectAccess`;内存授权器在无库时把任意正用户 id 视为成员,这是 `TestAuthorizeDocRevision_ReviewerAndNonMemberCannotWrite` 的隔离前提,不能改成默认拒绝。H2 的 HTTP 负例因此要在 `TEST_DATABASE_URL` 上补,本机未设置该变量。M7 的投影意图已随账本提交(goose `00053`);容器 schema 仍是 52,要等占槽重建后才升到 53。
+11 -50
View File
@@ -187,23 +187,18 @@ import {
import { resolveTaskTemplateForMessage } from './engine/templateMatch';
import {
loadSessions,
saveSessions,
loadActiveSessionId,
saveActiveSessionId,
loadDeliverables,
saveDeliverables,
loadUsers,
saveUsers,
migrateAndClearLegacyMessages,
purgeDemoSeedsFromLocalStorage,
resolveActiveSessionForUser,
loadProjects,
saveProjects,
filterProjectSessionsForUser,
getProjectMembership,
buildPrivateSessionId,
} from './data/storage';
import { useChatWindow } from './hooks/useChatWindow';
import { useWorkbenchPersistence } from './hooks/useWorkbenchPersistence';
import { useTurnRuntime } from './hooks/useTurnRuntime';
import { useTaskRunStream } from './hooks/useTaskRunStream';
import { fetchChatTurnStatus } from './api/chatTurn';
@@ -226,7 +221,7 @@ import { useUnreadMarks } from './hooks/useUnreadMarks';
import { CategoryDock } from './components/chat/CategoryDock';
import { fetchChatCategories, fetchChatDates, mapTaskStatusToMessageStatus } from './api/chatWindow';
import { ChatCategoryStat, ChatDateStat, QuotedMessage, AssistantMessageStatus } from './types/workbench';
import { saveFile, restoreFileRawToDeliverables, applyRestoredFileRaw } from './data/fileStore';
import { saveFile } from './data/fileStore';
import { uploadProjectAsset } from './api/projectAssets';
import { subscribeTaskRunEvents, createTaskRun, cancelTaskRun, getTaskRun } from './api/taskRuns';
@@ -297,23 +292,10 @@ export default function App() {
const [sessions, setSessions] = useState<ProjectSession[]>(() => loadSessions());
const [activeSessionId, setActiveSessionId] = useState<string>(() => loadActiveSessionId('sess-001'));
// 自动持久化 sessions 与 activeSessionId
useEffect(() => {
saveSessions(sessions);
}, [sessions]);
useEffect(() => {
saveActiveSessionId(activeSessionId);
}, [activeSessionId]);
// 全局角色库与人员名单状态 (支持动态增删改与持久化)
const [roles, setRoles] = useState<RoleDefinition[]>(() => DEFAULT_ROLES);
const [allUsers, setAllUsers] = useState<UserProfile[]>(() => loadUsers(MOCK_USERS));
useEffect(() => {
saveUsers(allUsers);
}, [allUsers]);
// 当前用户身份 (职位在全公司唯一不变,绑定全局角色,但在不同文档有不同法定职责)
const [currentUser, setCurrentUser] = useState<UserProfile>(() => allUsers[0] || MOCK_USERS[0] || {
id: 0,
@@ -332,24 +314,6 @@ export default function App() {
const deliverablesRef = useRef(deliverables);
deliverablesRef.current = deliverables;
// 自动持久化 deliverables(序列化时剔除不可持久化的 File 对象,见 storage.ts)
useEffect(() => {
saveDeliverables(deliverables);
}, [deliverables]);
// ✅ 启动时从 IndexedDB 恢复上传文件的 fileRaw(解决页面刷新后 File 对象丢失问题)
// 仅在组件 mount 时执行一次;恢复成功后对 deliverables 做最小化 patch(函数式合并,不覆盖期间新增)。
useEffect(() => {
let cancelled = false;
const snapshot = deliverablesRef.current;
restoreFileRawToDeliverables(snapshot).then((restored) => {
if (cancelled || restored === snapshot) return;
setDeliverables((prev) => applyRestoredFileRaw(prev, restored));
});
return () => { cancelled = true; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // 只在 mount 时运行一次
const [activeCategory, setActiveCategory] = useState<DeliverableCategory>('workflow');
// 项目与成员体系状态 (支持持久化)
@@ -358,9 +322,15 @@ export default function App() {
projectsRef.current = projects;
const memberInflightRef = useRef(new Map<number, Promise<ProjectMember[] | null>>());
const sessionEpochRef = useRef(new RequestEpoch());
useEffect(() => {
saveProjects(projects);
}, [projects]);
useWorkbenchPersistence({
sessions,
activeSessionId,
allUsers,
deliverables,
deliverablesRef,
setDeliverables,
projects,
});
const [createProjectModalOpen, setCreateProjectModalOpen] = useState(false);
const [createProjectError, setCreateProjectError] = useState<string | null>(null);
@@ -1588,10 +1558,6 @@ export default function App() {
// 消息流:权威来源为服务端窗口;不再全量持久化到 localStorage
const [messages, setMessages] = useState<ChatMessage[]>([]);
useEffect(() => {
void migrateAndClearLegacyMessages();
}, []);
// 基于当前激活的项目会话严格隔离对话记录
const currentSessionMessages = useMemo(() => {
return messages.filter((m) => m.sessionId === activeSessionId);
@@ -1809,11 +1775,6 @@ export default function App() {
setActiveQuotes((prev) => prev.filter((_, i) => i !== index));
};
// 挂载时检测并迁移旧版 localStorage 巨大 MESSAGES 键
useEffect(() => {
migrateAndClearLegacyMessages();
}, []);
// 当前会话是否存在正在等待/流式/自动重试中的助手气泡 (驱动 InputArea 停止态)
const isStreamingActive = useMemo(() => {
return currentSessionMessages.some(
@@ -0,0 +1,16 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
test('App persists workbench state through one hook and migrates legacy messages once', () => {
const app = readFileSync(new URL('../App.tsx', import.meta.url), 'utf8');
const hook = readFileSync(new URL('./useWorkbenchPersistence.ts', import.meta.url), 'utf8');
assert.equal((app.match(/useWorkbenchPersistence\(/g) || []).length, 1);
assert.equal(app.includes('migrateAndClearLegacyMessages'), false);
assert.equal(app.includes('saveSessions('), false);
assert.equal(app.includes('saveProjects('), false);
for (const name of ['saveSessions', 'saveProjects', 'saveDeliverables', 'saveUsers', 'migrateAndClearLegacyMessages']) {
assert.equal(hook.includes(name), true, `hook missing ${name}`);
}
assert.equal((hook.match(/migrateAndClearLegacyMessages/g) || []).length, 2);
});
+74
View File
@@ -0,0 +1,74 @@
import { useEffect, type Dispatch, type MutableRefObject, type SetStateAction } from 'react';
import {
migrateAndClearLegacyMessages,
saveActiveSessionId,
saveDeliverables,
saveProjects,
saveSessions,
saveUsers,
} from '../data/storage';
import { applyRestoredFileRaw, restoreFileRawToDeliverables } from '../data/fileStore';
import type { UserProfile } from '../types/docgov';
import type { DeliverableItem, ProjectInfo, ProjectSession } from '../types/workbench';
export interface WorkbenchPersistenceInput {
sessions: ProjectSession[];
activeSessionId: string;
allUsers: UserProfile[];
deliverables: DeliverableItem[];
deliverablesRef: MutableRefObject<DeliverableItem[]>;
setDeliverables: Dispatch<SetStateAction<DeliverableItem[]>>;
projects: ProjectInfo[];
}
/**
* 工作台本地缓存的写入与一次性迁移。
* 必须在对应 useState 初始化之后调用;demo seed 清理仍留在渲染前,不能放进 effect。
*/
export function useWorkbenchPersistence(input: WorkbenchPersistenceInput): void {
const {
sessions,
activeSessionId,
allUsers,
deliverables,
deliverablesRef,
setDeliverables,
projects,
} = input;
useEffect(() => {
saveSessions(sessions);
}, [sessions]);
useEffect(() => {
saveActiveSessionId(activeSessionId);
}, [activeSessionId]);
useEffect(() => {
saveUsers(allUsers);
}, [allUsers]);
useEffect(() => {
saveDeliverables(deliverables);
}, [deliverables]);
useEffect(() => {
saveProjects(projects);
}, [projects]);
useEffect(() => {
let cancelled = false;
const snapshot = deliverablesRef.current;
restoreFileRawToDeliverables(snapshot).then((restored) => {
if (cancelled || restored === snapshot) return;
setDeliverables((prev) => applyRestoredFileRaw(prev, restored));
});
return () => {
cancelled = true;
};
}, [deliverablesRef, setDeliverables]);
useEffect(() => {
void migrateAndClearLegacyMessages();
}, []);
}