fix: 消除会话列表解析阻塞并按需加载工具详情

This commit is contained in:
shiyue
2026-10-19 10:58:22 +08:00
parent fb2c3360c9
commit 99cc807f4b
35 changed files with 1468 additions and 214 deletions

View File

@@ -684,7 +684,7 @@ function assertFrontendSidebarCollapseContract() {
'Rich themes should provide isolated rail treatments on top of the shared semantic fallback'
);
assert(
indexSource.includes('style.css?v=20260913-history-control-position')
indexSource.includes('style.css?v=20261017-file-browser-image-gallery')
&& indexSource.includes('app.js?v=__CC_WEB_FRONTEND_ASSET_VERSION__'),
'Sidebar interaction assets should share the reviewed cache-busting version'
);
@@ -1231,7 +1231,7 @@ function assertPlanListProgressContract() {
assert(extractorSource.includes('references/source-assets/wasteland-icon-sheet.webp'), 'Plan progress extractor should read the archived source sheet');
assert(!extractorSource.includes('sessions/_attachments'), 'Plan progress extractor should not depend on temporary session attachments');
assert(indexSource.includes('style.css?v=20260913-history-control-position'), 'Plan progress CSS should use the current cache-busted URL');
assert(indexSource.includes('style.css?v=20261017-file-browser-image-gallery'), 'Plan progress CSS should use the current cache-busted URL');
assert(indexSource.includes('app.js?v=__CC_WEB_FRONTEND_ASSET_VERSION__'), 'Plan progress frontend logic should use the dynamic cache-busted URL');
}
@@ -1347,7 +1347,7 @@ function assertFrontendGildedThemeContract() {
assert(contrast('#655446', '#fff7ea') >= 4.5, 'Gilded muted text should remain readable on ivory panels');
assert(contrast('#fff7ea', '#7a3f20') >= 7, 'Gilded primary action text should reach AAA contrast on copper');
assert(themeStyle.includes('@media (prefers-reduced-motion: reduce)'), 'Gilded theme motion should respect reduced-motion preferences');
assert(indexSource.includes('style.css?v=20260913-history-control-position'), 'Theme bundle stylesheet should use the current cache-busted asset URL');
assert(indexSource.includes('style.css?v=20261017-file-browser-image-gallery'), 'Theme bundle stylesheet should use the current cache-busted asset URL');
assert(indexSource.includes('app.js?v=__CC_WEB_FRONTEND_ASSET_VERSION__'), 'Theme bundle app script should use the dynamic cache-busted asset URL');
}
@@ -1601,7 +1601,7 @@ function assertFrontendWastelandThemeContract() {
assert(contrast('#c9bda6', backgroundColor) >= 4.5, `Wasteland muted text should reach AA contrast on ${backgroundColor}`);
});
assert(indexSource.includes('style.css?v=20260913-history-control-position'), 'Wasteland stylesheet should share the cache-busted theme bundle URL');
assert(indexSource.includes('style.css?v=20261017-file-browser-image-gallery'), 'Wasteland stylesheet should share the cache-busted theme bundle URL');
assert(indexSource.includes('app.js?v=__CC_WEB_FRONTEND_ASSET_VERSION__'), 'Wasteland registration should share the dynamic cache-busted theme bundle URL');
}
@@ -1652,7 +1652,7 @@ function assertFrontendFileBrowserImageContract() {
assert(browser.includes('/api/fs/image?sessionId='), 'Image previews should use the authenticated image endpoint');
assert(browser.includes('/api/fs/read?sessionId='), 'Text previews should retain the text endpoint');
assert(source.includes('async function fetchAuthImage(url, signal)') && source.includes('Authorization: `Bearer ${authToken}`'), 'Image fetch should use Bearer authentication');
assert(browser.includes("mimeType === 'image/svg+xml'") && browser.includes('reader.readAsDataURL(blob);'), 'SVG previews should use isolated data URLs');
assert(browser.includes("mimeType !== 'image/svg+xml'") && browser.includes('reader.readAsDataURL(blob);'), 'SVG previews should use isolated data URLs');
assert(browser.includes('URL.createObjectURL(blob)') && browser.includes('URL.revokeObjectURL(state.previewImageUrl)'), 'Raster previews should manage object URL lifetime');
assert(browser.includes('state.previewAbortController?.abort()') && browser.includes('requestId !== state.previewRequestId'), 'Switching previews should abort or reject stale image requests');
assert(browser.includes('cancelFileBrowserPreview(state)') && browser.includes('clearFileBrowserImagePreview(fileBrowserState)'), 'Closing and switching views should release image resources');
@@ -1680,7 +1680,7 @@ function assertFrontendFileBrowserImageContract() {
overlay: { parentNode: { removeChild() {} } },
escapeHandler() {},
};
const createLifecycle = new Function('state', 'URL', 'document', `let fileBrowserState = state;\n${lifecycle}\nreturn { cancelFileBrowserPreview, renderFileBrowserPreviewLoading, closeFileBrowser };`);
const createLifecycle = new Function('state', 'URL', 'document', `let fileBrowserState = state;\nlet attachmentPreviewModal = null;\n${lifecycle}\nreturn { cancelFileBrowserPreview, renderFileBrowserPreviewLoading, closeFileBrowser };`);
const actions = createLifecycle(state, { revokeObjectURL(url) { released.push(url); } }, { removeEventListener() {} });
actions.cancelFileBrowserPreview(state);
assert(aborted === 1 && state.previewRequestId === 4 && state.previewImageUrl === '', 'Switching previews should abort the old request and invalidate its response');
@@ -1693,6 +1693,121 @@ function assertFrontendFileBrowserImageContract() {
assert(released[2] === 'blob:third' && state.previewRequestId === 5, 'Closing the browser should release its image and invalidate pending responses');
}
async function assertFrontendFileBrowserImageGallery() {
const source = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
const styles = fs.readFileSync(PUBLIC_STYLE_PATH, 'utf8');
const functions = [
'isFileBrowserImagePath',
'getFileBrowserImageGallery',
'clearAttachmentPreviewImage',
'closeAttachmentPreviewModal',
'stepAttachmentPreviewImage',
].map((name) => extractFunctionSource(source, name)).join('\n');
const loadImageSource = `async ${extractFunctionSource(source, 'loadAttachmentPreviewImage')}`;
const requests = [];
const revoked = [];
let focusRestored = 0;
let removed = 0;
const makeElement = () => ({
textContent: '',
hidden: false,
disabled: false,
src: '',
alt: '',
classList: { add() {}, remove() {} },
removeAttribute(name) { if (name === 'src') this.src = ''; },
});
const api = new Function('getFileBrowserImagePreview', 'getAttachmentPreviewUrl', 'URL', 'document', `
let attachmentPreviewModal = null;
const FILE_BROWSER_IMAGE_EXTENSIONS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg', 'avif']);
const normalizeBrowserPath = (value) => value;
const getPathLeaf = (value) => value.split('/').pop();
const formatFileSize = (value) => String(value);
${functions}
${loadImageSource}
return {
getFileBrowserImageGallery, loadAttachmentPreviewImage, stepAttachmentPreviewImage,
closeAttachmentPreviewModal,
activate(state) { attachmentPreviewModal = state; },
current() { return attachmentPreviewModal; },
};
`)(
(sessionId, filePath, signal) => new Promise((resolve, reject) => requests.push({ sessionId, filePath, signal, resolve, reject })),
async () => 'blob:message-cache',
{ revokeObjectURL(url) { revoked.push(url); } },
{ removeEventListener() {} }
);
const gallery = api.getFileBrowserImageGallery({
sessionId: 'session-1',
directoryEntries: [
{ kind: 'directory', path: 'folder.png', imagePreviewableHint: true },
{ kind: 'file', path: 'b.png', imagePreviewableHint: true },
{ kind: 'file', path: 'text.png', imagePreviewableHint: false },
{ kind: 'file', path: 'linked-image.data', name: '链接图片', imagePreviewableHint: true },
{ kind: 'file', path: 'notes.txt', imagePreviewableHint: false },
{ kind: 'file', path: 'a.SVG' },
],
});
assert(gallery.map((item) => item.filePath).join(',') === 'b.png,linked-image.data,a.SVG', 'Gallery should preserve directory order, include image symlinks, and skip directories/text');
assert(gallery[1].filename === '链接图片' && gallery[1].sessionId === 'session-1', 'Gallery items should retain display names and session authentication context');
const state = {
gallery, index: 0, requestId: 0, imageObjectUrl: '',
imgEl: makeElement(), stageEl: makeElement(), placeholderEl: makeElement(),
nameEl: makeElement(), descEl: makeElement(),
previousBtn: makeElement(), nextBtn: makeElement(),
overlay: { remove() { removed += 1; } },
returnFocus: { isConnected: true, focus() { focusRestored += 1; } },
};
api.activate(state);
const firstLoad = api.loadAttachmentPreviewImage(state, 0);
assert(state.previousBtn.disabled && !state.nextBtn.disabled && state.descEl.textContent.startsWith('1 / 3'), 'First image should disable previous and show its position while loading');
const lastLoad = api.loadAttachmentPreviewImage(state, 2);
assert(requests[0].signal.aborted && state.nextBtn.disabled && !state.previousBtn.disabled, 'Switching should cancel the old request and disable next at the last image');
requests[0].resolve({ url: 'blob:stale', objectUrl: true });
await firstLoad;
assert(revoked.includes('blob:stale') && state.imgEl.src === '', 'Late raster responses should be released without replacing the current image');
requests[1].resolve({ url: 'data:image/svg+xml;base64,PHN2Zy8+', objectUrl: false, blob: { size: 12 }, mimeType: 'image/svg+xml' });
await lastLoad;
state.imgEl.onload();
assert(!state.imgEl.hidden && state.placeholderEl.hidden && state.nameEl.textContent === 'a.SVG' && state.descEl.textContent.startsWith('3 / 3'), 'The selected image should render with its filename and page count');
api.stepAttachmentPreviewImage(state, 1);
assert(requests.length === 2, 'Next at the last image should not wrap or request another image');
api.stepAttachmentPreviewImage(state, -1);
requests[2].reject(new Error('图片不存在'));
await new Promise((resolve) => setImmediate(resolve));
assert(state.placeholderEl.textContent === '图片不存在' && !state.placeholderEl.hidden && state.imgEl.hidden, 'Failed gallery images should retain an error state and allow further navigation');
const rasterLoad = api.loadAttachmentPreviewImage(state, 0);
requests[3].resolve({ url: 'blob:active', objectUrl: true });
await rasterLoad;
const pendingLoad = api.loadAttachmentPreviewImage(state, 1);
assert(revoked.includes('blob:active'), 'Leaving a raster image should release its Blob URL');
api.closeAttachmentPreviewModal();
assert(requests[4].signal.aborted && api.current() === null && removed === 1 && focusRestored === 1, 'Closing the gallery should abort loading, remove the overlay and restore focus');
requests[4].resolve({ url: 'blob:after-close', objectUrl: true });
await pendingLoad;
assert(revoked.includes('blob:after-close'), 'A response arriving after close should also be released');
const messageState = { ...state, gallery: null, previousBtn: null, nextBtn: null, attachment: { filename: 'message.png', size: 5 }, requestId: 0 };
api.activate(messageState);
await api.loadAttachmentPreviewImage(messageState, 0);
assert(messageState.imgEl.src === 'blob:message-cache', 'Message image previews should keep using the existing attachment cache');
api.closeAttachmentPreviewModal();
assert(!revoked.includes('blob:message-cache'), 'Closing a message image should preserve the shared attachment cache URL');
const singleState = { ...state, gallery: [gallery[0]], requestId: 0 };
api.activate(singleState);
const singleLoad = api.loadAttachmentPreviewImage(singleState, 0);
assert(singleState.previousBtn.disabled && singleState.nextBtn.disabled && singleState.descEl.textContent.startsWith('1 / 1'), 'Single-image folders should disable both navigation controls');
api.closeAttachmentPreviewModal();
requests[5].resolve({ url: 'data:image/svg+xml;base64,PHN2Zy8+', objectUrl: false });
await singleLoad;
const modalSource = extractFunctionSource(source, 'openAttachmentPreviewModal');
const browserSource = extractFunctionSource(source, 'showFileBrowser');
assert(modalSource.includes("document.addEventListener('keydown', state.escapeHandler, true)") && modalSource.includes('e.stopImmediatePropagation()'), 'The topmost preview should consume Escape before the file browser handler');
assert(modalSource.includes("e.key === 'ArrowLeft'") && modalSource.includes("e.key === 'ArrowRight'"), 'Gallery should support both arrow keys');
assert(browserSource.includes('<button class="file-browser-preview-image"') && browserSource.includes("state.previewImageEl.addEventListener('click', openFileBrowserImageGallery)"), 'The file image preview should be a keyboard-accessible button');
assert(styles.includes('.attachment-preview-overlay.is-fullscreen') && styles.includes('height: 100dvh;') && styles.includes('touch-action: manipulation;'), 'The gallery should fill the viewport and retain touch-friendly navigation');
}
function assertMarkdownRenderingContract() {
const source = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
const indexSource = fs.readFileSync(PUBLIC_INDEX_PATH, 'utf8');
@@ -2851,7 +2966,7 @@ function assertGoalModeTitleContract() {
assert(styleSource.includes('.msg.user.goal-message .msg-bubble'), 'Goal label styling should remain scoped to user Goal bubbles');
assert(styleSource.includes('.goal-message-label'), 'Goal mode label should have a dedicated style');
assert(styleSource.includes("html[data-theme='wasteland'] .msg.user.goal-message .goal-message-label"), 'Wasteland should provide a dedicated Goal label treatment');
assert(indexSource.includes('style.css?v=20260913-history-control-position'), 'Goal label CSS should use the current cache-busted URL');
assert(indexSource.includes('style.css?v=20261017-file-browser-image-gallery'), 'Goal label CSS should use the current cache-busted URL');
assert(serverSource.includes('GOAL_DERIVED_TITLE_MAX_CHARS = 60'), 'Goal-derived titles should keep the 60-character limit');
assert(serverSource.includes('function isDefaultConversationTitle(session)'), 'Goal title updates should use a default-title guard');
assert(serverSource.includes('function applyDerivedGoalConversationTitle(session, objective)'), 'Goal title updates should stay in the /goal-specific server path');
@@ -6703,7 +6818,7 @@ function assertAdvancedSessionSearchContract() {
'Existing sidebar search clear control contract should remain unchanged');
assert(indexSource.includes('id="advanced-search-panel"') && indexSource.includes('id="advanced-search-results"'),
'Advanced search workspace should expose stable panel and result hooks');
assert(indexSource.includes('style.css?v=20260913-history-control-position')
assert(indexSource.includes('style.css?v=20261017-file-browser-image-gallery')
&& indexSource.includes('app.js?v=__CC_WEB_FRONTEND_ASSET_VERSION__'),
'Advanced search CSS and frontend script should share the reviewed cache-bust');
@@ -6820,7 +6935,7 @@ function assertUsageStatisticsContract() {
'Usage dashboard should be a chat-main-local workspace');
assert(indexSource.includes('class="usage-dashboard-open"') && !indexSource.includes('class="settings-btn usage-dashboard-open"'),
'Usage entry must use its own class so theme-specific settings pseudo-elements cannot leak');
assert(indexSource.includes('style.css?v=20260913-history-control-position')
assert(indexSource.includes('style.css?v=20261017-file-browser-image-gallery')
&& indexSource.includes('vendor/echarts.min.js?v=5.6.0')
&& indexSource.includes('app.js?v=__CC_WEB_FRONTEND_ASSET_VERSION__'),
'Usage dashboard CSS, local ECharts runtime, and frontend script should share the current asset contract');
@@ -7278,7 +7393,7 @@ function makeLargeSessionPreviewFixture(sessionId, tailFields = {}) {
],
},
],
padding: 'x'.repeat(70 * 1024),
padding: 'x'.repeat(256 * 1024),
id: sessionId,
...tailFields,
});
@@ -7292,7 +7407,7 @@ function makeHeadTailJoinRiskFixture(sessionId, previewBytes) {
assert(paddingBytes > 0, 'Boundary fixture should fit the preview head');
const head = `${beforePadding}${'x'.repeat(paddingBytes)}${beforeCwd}`;
assert(Buffer.byteLength(head) === previewBytes, 'Boundary fixture should end the head preview after cwd colon');
return `${head}${' '.repeat(70 * 1024)}"/tmp/joined-preview-cwd","messages":[]}`;
return `${head}${' '.repeat(previewBytes + 70 * 1024)}"/tmp/joined-preview-cwd","messages":[]}`;
}
async function runSessionPreviewMetadataRegression() {
@@ -7307,7 +7422,7 @@ async function runSessionPreviewMetadataRegression() {
mkdirp(homeDir);
const fullParseBytes = 64 * 1024;
const previewBytes = 16 * 1024;
const previewBytes = 128 * 1024;
const sourceSessionId = 'preview-parent-source';
const tailCwd = path.join(homeDir, '上下游协同平台');
const tailSessionId = 'preview-tail-top-level';
@@ -7356,6 +7471,17 @@ async function runSessionPreviewMetadataRegression() {
boundarySessionPath,
makeHeadTailJoinRiskFixture(boundarySessionId, previewBytes)
);
// 尾部高密度嵌套伪字段曾触发重复JSON解析,阻塞整个服务的HTTP读取。
const denseIds = Array.from({ length: 8 }, (_, index) => `preview-dense-${index}`);
const denseMessages = Array.from({ length: 6500 }, () => ({
id: 'tool-id', title: '工具标题', cwd: '/错误目录', agent: 'claude', input: '转义\\"及括号}]',
}));
for (const id of denseIds) fs.writeFileSync(path.join(sessionsDir, `${id}.json`), JSON.stringify({
padding: 'x'.repeat(256 * 1024), messages: denseMessages, id, title: '真实顶层标题', agent: 'codexapp', cwd: tailCwd,
}));
fs.writeFileSync(path.join(sessionsDir, 'preview-http-target.json'), JSON.stringify({
id: 'preview-http-target', title: 'HTTP读取目标', agent: 'codexapp', messages: [{ id: 'target-message', role: 'user', content: '真实正文' }],
}));
assert(fs.statSync(tailSessionPath).size > fullParseBytes, 'Tail fixture must use oversized preview parsing');
assert(fs.statSync(childSessionPath).size > fullParseBytes, 'MCP child fixture must use oversized preview parsing');
assert(fs.statSync(nullSessionPath).size > fullParseBytes, 'Null fixture must use oversized preview parsing');
@@ -7376,7 +7502,7 @@ async function runSessionPreviewMetadataRegression() {
CLAUDE_PATH: MOCK_CLAUDE,
CODEX_PATH: MOCK_CODEX_APP_SERVER,
}, async () => {
const { ws, messages } = await connectWs(port, password);
const { ws, messages, token } = await connectWs(port, password);
const list = await nextMessage(messages, ws, (msg) => (
msg.type === 'session_list' &&
[tailSessionId, childSessionId, nullSessionId, boundarySessionId].every((id) => (
@@ -7402,6 +7528,25 @@ async function runSessionPreviewMetadataRegression() {
assert(boundarySession.cwd === '', `Preview parser should not join head cwd key with tail value, got ${JSON.stringify(boundarySession.cwd)}`);
assert(boundarySession.projectName === '', `Boundary preview should not derive a projectName from a joined value, got ${JSON.stringify(boundarySession.projectName)}`);
for (const id of denseIds) {
assert(sessionById.get(id)?.title === '真实顶层标题', '高密度工具伪标题不能污染会话标题');
assert(sessionById.get(id)?.cwd === tailCwd, '高密度工具伪目录不能污染会话目录');
}
const started = Date.now();
const oldListCount = messages.filter(message => message.type === 'session_list').length;
ws.send(JSON.stringify({ type: 'list_sessions' }));
const [loaded] = await Promise.all([
(async () => {
const response = await fetch(`http://127.0.0.1:${port}/api/sessions/preview-http-target?requestId=metadata-concurrent-http`, {
headers: { Authorization: `Bearer ${token}` }, signal: AbortSignal.timeout(3000),
});
assert(response.ok, '刷新列表时HTTP读取必须成功');
return response.json();
})(),
nextMessage(messages, ws, message => message.type === 'session_list' && messages.filter(item => item.type === 'session_list').length > oldListCount, 3000),
]);
assert(loaded.type === 'session_info' && loaded.messages[0]?.content === '真实正文', '刷新列表时HTTP切换仍须返回完整首屏');
assert(Date.now() - started < 3000, '高密度列表解析不得让并发HTTP切换排队数秒');
const childList = await callInternalMcp(port, 'SessionPreviewMcp!234', {
tool: 'ccweb_list_conversations',
@@ -7583,6 +7728,7 @@ function assertCcwebDisplayImageContract() {
const inline = prepareImagePayload({ source: 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==' });
assert(inline.ok && inline.image.mimeType === 'image/gif', 'Base64 GIF images should be accepted');
assert(prepareImagePayload({ source: 'relative.png' }).code === 'invalid_local_path', 'Local images should require absolute paths');
require('./ccweb-display-image-unit').run();
}
function assertCcwebListConversationsScopeContract() {
@@ -7795,6 +7941,7 @@ async function main() {
}
if (regressionTarget === 'file-browser-image') {
assertFrontendFileBrowserImageContract();
await assertFrontendFileBrowserImageGallery();
await runFileBrowserImageRegression();
console.log('File browser image regression checks passed.');
return;
@@ -7856,6 +8003,7 @@ async function main() {
assertFrontendCcwebPromptContract();
assertFrontendMarkdownLinkContract();
assertFrontendFileBrowserImageContract();
await assertFrontendFileBrowserImageGallery();
assertMarkdownRenderingContract();
assertMockCodexAppPromptUserNotTextTriggered();
assertFrontendMcpReloadContract();