refactor(web): move login workspace sync out of App

Project list, member roster, and session fetch ran as four effects inside the workbench component. They now share one hook so the entry file is not the only place that knows the login startup order.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-09-19 11:05:29 +00:00
co-authored by Cursor
parent bfe8cb1407
commit 27c1be86d5
4 changed files with 235 additions and 127 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 | 部分。登录壳不再静态引用重模块。本地缓存写入收到 `useWorkbenchPersistence`(含一次旧消息键迁移)。工作台主体仍在 `App.tsx`,其余 effect 未拆完 |
| FE App.tsx | 部分。登录壳不再静态引用重模块。本地缓存写入在 `useWorkbenchPersistence`。登录后的项目/成员/会话同步在 `useAuthenticatedWorkspaceSync`。工作台主体仍在 `App.tsx` |
| 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。
+25 -126
View File
@@ -19,14 +19,14 @@ import {
deleteFreeformDoc,
} from './api/freeformDoc';
import { registerUploadAttachments } from './lib/uploadFreeformRegister';
import { fetchChatSessions, upsertChatSession } from './api/chatSessions';
import { upsertChatSession } from './api/chatSessions';
import { listProjectWorkflowDocuments } from './api/projectDocuments';
import type { DiffResult, OverlayMode } from './features/documentRevision/types';
import {
documentRevisionInactiveHint,
revisionToolbarMountMode,
} from './features/documentRevision/revisionToolbarMount';
import { createProject, fetchProjectLineage, fetchProjects, getProject } from './api/projectWorkflow';
import { createProject, fetchProjectLineage, getProject } from './api/projectWorkflow';
import {
addProjectMember,
listProjectMembers,
@@ -198,17 +198,15 @@ import {
buildPrivateSessionId,
} from './data/storage';
import { useChatWindow } from './hooks/useChatWindow';
import { useAuthenticatedWorkspaceSync } from './hooks/useAuthenticatedWorkspaceSync';
import { useWorkbenchPersistence } from './hooks/useWorkbenchPersistence';
import { useTurnRuntime } from './hooks/useTurnRuntime';
import { useTaskRunStream } from './hooks/useTaskRunStream';
import { fetchChatTurnStatus } from './api/chatTurn';
import { createClientTurnId } from './lib/createClientTurnId';
import { mergeSessionUser } from './lib/mergeSessionUser';
import { mapServerProjectToProjectInfo, mergeProjectLists } from './lib/projectAdapter';
import { loginMemberSyncIds, RequestEpoch, seedViewerMembership, shareInflight } from './data/initFanout';
import { RequestEpoch, shareInflight } from './data/initFanout';
import {
mapServerSessionToProjectSession,
mergeSessionLists,
resolveSessionProjectId,
} from './lib/sessionAdapter';
import { SendMessageResult } from './types/sendMessage';
@@ -476,126 +474,27 @@ export default function App() {
const [capabilities, setCapabilities] = useState<string[]>(() => authClient.getCapabilities());
useEffect(() => {
authClient.getSystemAuthConfig().then(setSystemAuthConfig).catch(() => {});
const saved = authClient.loadSavedSession();
if (saved) {
setSession(saved);
if (saved.env) setSystemEnv(saved.env);
setCapabilities(saved.capabilities ?? []);
const match = allUsers.find((u) => u.id === saved.user.id);
setCurrentUser(mergeSessionUser(saved.user, match));
void authClient
.fetchMe()
.then((me) => {
setCapabilities(me.capabilities ?? []);
setCurrentUser(mergeSessionUser(me.user, match));
setSessionValidated(true);
})
.catch(() => {
authClient.clearLocalSession();
setSession(null);
setCapabilities([]);
setSessionValidated(false);
});
} else {
setSessionValidated(false);
}
}, []);
useEffect(() => {
if (!session || sessionValidated !== true) {
setProjectSyncNotice(null);
return;
}
let cancelled = false;
void (async () => {
try {
const serverProjects = await fetchProjects();
if (cancelled) return;
setProjects((prev) => {
const mapped = (serverProjects ?? []).map((project) =>
seedViewerMembership(
mapServerProjectToProjectInfo(
project,
allUsers,
prev.find((item) => item.id === project.id)
),
currentUser.id,
currentUser.name
)
);
return mergeProjectLists(prev, mapped);
});
const syncIds = loginMemberSyncIds(
(serverProjects ?? []).map((project) => project.id),
currentProjectId
);
await Promise.all(
syncIds.map((id) => {
const project = serverProjects.find((item) => item.id === id);
if (!project) return Promise.resolve(null);
return syncProjectMembersFromServer(project.id, project.creator_user_id);
})
);
setProjectSyncNotice(null);
} catch (err) {
if (cancelled) return;
console.warn('[projects] 服务端项目列表同步失败,继续使用本地缓存:', err);
setProjectSyncNotice('项目列表暂用本地缓存:无法连接服务端,新建项目可能无法在其他设备同步。');
}
})();
return () => {
cancelled = true;
};
}, [session, sessionValidated, allUsers, currentUser.id, currentUser.name, currentProjectId, syncProjectMembersFromServer]);
useEffect(() => {
if (!projectMembersModalOpen || !currentProjectId || !session || sessionValidated !== true) return;
const project = projectsRef.current.find((item) => item.id === currentProjectId);
if (!project) return;
void syncProjectMembersFromServer(project.id, project.creatorId);
}, [projectMembersModalOpen, currentProjectId, session, sessionValidated, syncProjectMembersFromServer]);
useEffect(() => {
if (!session || sessionValidated !== true || !currentUser.id) {
setSessionSyncNotice(null);
return;
}
const abort = new AbortController();
const epoch = sessionEpochRef.current.next();
void (async () => {
try {
const serverSessions = await fetchChatSessions(currentUser.id, undefined, 100, abort.signal);
if (abort.signal.aborted || !sessionEpochRef.current.isCurrent(epoch)) return;
const knownProjects = projectsRef.current;
setSessions((prev) => {
const mapped = (serverSessions ?? []).map((item) =>
mapServerSessionToProjectSession(
item,
knownProjects,
prev.find((sessionItem) => sessionItem.sessionId === item.session_id)
)
);
return mergeSessionLists(prev, mapped);
});
setSessionSyncNotice(null);
} catch (err) {
if (abort.signal.aborted || !sessionEpochRef.current.isCurrent(epoch)) return;
console.warn('[sessions] 服务端会话列表同步失败,继续使用本地缓存:', err);
setSessionSyncNotice(
'会话列表暂用本地缓存:无法连接服务端,新建会话可能无法在其他设备同步。'
);
}
})();
return () => {
abort.abort();
};
}, [session, sessionValidated, currentUser.id]);
useAuthenticatedWorkspaceSync({
allUsers,
setCurrentUser,
setSession,
setSessionValidated,
setSystemEnv,
setSystemAuthConfig,
setCapabilities,
session,
sessionValidated,
currentUser,
currentProjectId,
syncProjectMembersFromServer,
setProjects,
setProjectSyncNotice,
projectMembersModalOpen,
projectsRef,
setSessions,
setSessionSyncNotice,
sessionEpochRef,
});
// handleLogout 定义在 turnRuntime/taskRunStream hook 之后,便于 abort 全会话在途流。
@@ -0,0 +1,15 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
test('login workspace sync lives in one hook', () => {
const app = readFileSync(new URL('../App.tsx', import.meta.url), 'utf8');
const hook = readFileSync(new URL('./useAuthenticatedWorkspaceSync.ts', import.meta.url), 'utf8');
assert.equal((app.match(/useAuthenticatedWorkspaceSync\(/g) || []).length, 1);
assert.equal(app.includes('fetchProjects('), false);
assert.equal(app.includes('fetchChatSessions('), false);
assert.equal(hook.includes('fetchProjects'), true);
assert.equal(hook.includes('fetchChatSessions'), true);
assert.equal(hook.includes('loginMemberSyncIds'), true);
assert.equal(hook.includes('.fetchMe()'), true);
});
@@ -0,0 +1,194 @@
import { useEffect, type Dispatch, type MutableRefObject, type SetStateAction } from 'react';
import { authClient } from '../api/auth';
import { fetchChatSessions } from '../api/chatSessions';
import { fetchProjects } from '../api/projectWorkflow';
import { loginMemberSyncIds, RequestEpoch, seedViewerMembership } from '../data/initFanout';
import { mergeSessionUser } from '../lib/mergeSessionUser';
import { mapServerProjectToProjectInfo, mergeProjectLists } from '../lib/projectAdapter';
import { mapServerSessionToProjectSession, mergeSessionLists } from '../lib/sessionAdapter';
import type { LoginResult, SystemAuthConfig } from '../types/auth';
import type { UserProfile } from '../types/docgov';
import type { ProjectInfo, ProjectSession } from '../types/workbench';
export interface AuthenticatedWorkspaceSyncInput {
allUsers: UserProfile[];
setCurrentUser: Dispatch<SetStateAction<UserProfile>>;
setSession: Dispatch<SetStateAction<LoginResult | null>>;
setSessionValidated: Dispatch<SetStateAction<boolean | null>>;
setSystemEnv: Dispatch<SetStateAction<string>>;
setSystemAuthConfig: Dispatch<SetStateAction<SystemAuthConfig>>;
setCapabilities: Dispatch<SetStateAction<string[]>>;
session: LoginResult | null;
sessionValidated: boolean | null;
currentUser: UserProfile;
currentProjectId: number;
syncProjectMembersFromServer: (projectId: number, creatorId: number) => Promise<unknown>;
setProjects: Dispatch<SetStateAction<ProjectInfo[]>>;
setProjectSyncNotice: Dispatch<SetStateAction<string | null>>;
projectMembersModalOpen: boolean;
projectsRef: MutableRefObject<ProjectInfo[]>;
setSessions: Dispatch<SetStateAction<ProjectSession[]>>;
setSessionSyncNotice: Dispatch<SetStateAction<string | null>>;
sessionEpochRef: MutableRefObject<RequestEpoch>;
}
/**
* 已登录工作台的启动同步:校验会话、拉项目、按需同步当前项目成员、拉会话列表。
* 首次校验故意只跑一次,沿用入口组件原来的空依赖,避免用户目录更新把登录态重放一遍。
*/
export function useAuthenticatedWorkspaceSync(input: AuthenticatedWorkspaceSyncInput): void {
const {
allUsers,
setCurrentUser,
setSession,
setSessionValidated,
setSystemEnv,
setSystemAuthConfig,
setCapabilities,
session,
sessionValidated,
currentUser,
currentProjectId,
syncProjectMembersFromServer,
setProjects,
setProjectSyncNotice,
projectMembersModalOpen,
projectsRef,
setSessions,
setSessionSyncNotice,
sessionEpochRef,
} = input;
useEffect(() => {
authClient.getSystemAuthConfig().then(setSystemAuthConfig).catch(() => {});
const saved = authClient.loadSavedSession();
if (saved) {
setSession(saved);
if (saved.env) setSystemEnv(saved.env);
setCapabilities(saved.capabilities ?? []);
const match = allUsers.find((u) => u.id === saved.user.id);
setCurrentUser(mergeSessionUser(saved.user, match));
void authClient
.fetchMe()
.then((me) => {
setCapabilities(me.capabilities ?? []);
setCurrentUser(mergeSessionUser(me.user, match));
setSessionValidated(true);
})
.catch(() => {
authClient.clearLocalSession();
setSession(null);
setCapabilities([]);
setSessionValidated(false);
});
} else {
setSessionValidated(false);
}
// 与原入口一致:只在挂载时恢复本地会话。
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (!session || sessionValidated !== true) {
setProjectSyncNotice(null);
return;
}
let cancelled = false;
void (async () => {
try {
const serverProjects = await fetchProjects();
if (cancelled) return;
setProjects((prev) => {
const mapped = (serverProjects ?? []).map((project) =>
seedViewerMembership(
mapServerProjectToProjectInfo(
project,
allUsers,
prev.find((item) => item.id === project.id)
),
currentUser.id,
currentUser.name
)
);
return mergeProjectLists(prev, mapped);
});
const syncIds = loginMemberSyncIds(
(serverProjects ?? []).map((project) => project.id),
currentProjectId
);
await Promise.all(
syncIds.map((id) => {
const project = serverProjects.find((item) => item.id === id);
if (!project) return Promise.resolve(null);
return syncProjectMembersFromServer(project.id, project.creator_user_id);
})
);
setProjectSyncNotice(null);
} catch (err) {
if (cancelled) return;
console.warn('[projects] 服务端项目列表同步失败,继续使用本地缓存:', err);
setProjectSyncNotice('项目列表暂用本地缓存:无法连接服务端,新建项目可能无法在其他设备同步。');
}
})();
return () => {
cancelled = true;
};
}, [
session,
sessionValidated,
allUsers,
currentUser.id,
currentUser.name,
currentProjectId,
syncProjectMembersFromServer,
setProjects,
setProjectSyncNotice,
]);
useEffect(() => {
if (!projectMembersModalOpen || !currentProjectId || !session || sessionValidated !== true) return;
const project = projectsRef.current.find((item) => item.id === currentProjectId);
if (!project) return;
void syncProjectMembersFromServer(project.id, project.creatorId);
}, [projectMembersModalOpen, currentProjectId, session, sessionValidated, syncProjectMembersFromServer, projectsRef]);
useEffect(() => {
if (!session || sessionValidated !== true || !currentUser.id) {
setSessionSyncNotice(null);
return;
}
const abort = new AbortController();
const epoch = sessionEpochRef.current.next();
void (async () => {
try {
const serverSessions = await fetchChatSessions(currentUser.id, undefined, 100, abort.signal);
if (abort.signal.aborted || !sessionEpochRef.current.isCurrent(epoch)) return;
const knownProjects = projectsRef.current;
setSessions((prev) => {
const mapped = (serverSessions ?? []).map((item) =>
mapServerSessionToProjectSession(
item,
knownProjects,
prev.find((sessionItem) => sessionItem.sessionId === item.session_id)
)
);
return mergeSessionLists(prev, mapped);
});
setSessionSyncNotice(null);
} catch (err) {
if (abort.signal.aborted || !sessionEpochRef.current.isCurrent(epoch)) return;
console.warn('[sessions] 服务端会话列表同步失败,继续使用本地缓存:', err);
setSessionSyncNotice(
'会话列表暂用本地缓存:无法连接服务端,新建会话可能无法在其他设备同步。'
);
}
})();
return () => {
abort.abort();
};
}, [session, sessionValidated, currentUser.id, setSessions, setSessionSyncNotice, sessionEpochRef, projectsRef]);
}