优化目录候选文件名显示并重新打包

This commit is contained in:
shiyue
2026-09-11 13:56:43 +08:00
parent b8fcc12229
commit 965aefe9a4
6 changed files with 371 additions and 14 deletions

View File

@@ -11556,6 +11556,30 @@
};
}
function getComposerFileSuggestionDisplay(item) {
const rawName = String(item?.name || item?.label || item?.insertion || '').trim();
const normalizedName = rawName
.replace(/^@/, '')
.replace(/\\/g, '/')
.replace(/\/+$/, '');
const segments = normalizedName.split('/').filter(Boolean);
const basename = segments.pop() || normalizedName || rawName;
const parentPath = segments.join('/');
const isDirectory = item?.itemType === 'directory';
const primaryText = `${basename}${isDirectory ? '/' : ''}`;
const parentLabel = parentPath ? `@${parentPath}/` : '';
const fallbackDescription = String(item?.description || '').trim();
const secondaryText = [parentLabel, parentLabel ? '' : fallbackDescription]
.filter(Boolean)
.join(' · ')
|| fallbackDescription;
return {
primaryText,
secondaryText,
fullPath: String(item?.label || item?.insertion || rawName).trim(),
};
}
function showCmdMenu(token, items) {
const safeItems = Array.isArray(items) ? items : [];
if (!token || safeItems.length === 0) {
@@ -11565,6 +11589,8 @@
activeComposerToken = token;
cmdMenuIndex = 0;
cmdMenu.innerHTML = safeItems.map((item, i) => {
const isFileSuggestion = item.kind === 'file';
const fileDisplay = isFileSuggestion ? getComposerFileSuggestionDisplay(item) : null;
const kindLabel = item.kind === 'skill'
? 'Skill'
: item.kind === 'prompt'
@@ -11574,11 +11600,14 @@
: item.kind === 'mcp'
? 'MCP'
: 'Cmd';
return `<div class="cmd-item${i === 0 ? ' active' : ''}" data-index="${i}">
const primaryText = fileDisplay?.primaryText || item.label || item.name || item.insertion || '';
const secondaryText = fileDisplay?.secondaryText || item.description || item.title || '';
const fullPathTitle = fileDisplay?.fullPath ? ` title="${escapeHtmlAttr(fileDisplay.fullPath)}"` : '';
return `<div class="cmd-item${i === 0 ? ' active' : ''}${isFileSuggestion ? ' file-suggestion' : ''}" data-index="${i}"${fullPathTitle}>
<span class="cmd-item-kind">${kindLabel}</span>
<span class="cmd-item-main">
<span class="cmd-item-cmd">${escapeHtml(item.label || item.name || item.insertion || '')}</span>
<span class="cmd-item-desc">${escapeHtml(item.description || item.title || '')}</span>
<span class="cmd-item-cmd">${escapeHtml(primaryText)}</span>
<span class="cmd-item-desc">${escapeHtml(secondaryText)}</span>
</span>
</div>`;
}).join('');

View File

@@ -23,7 +23,7 @@
document.documentElement.dataset.dividerTime = dividerTime;
})();
</script>
<link rel="stylesheet" href="style.css?v=20260818-goal-mode-label">
<link rel="stylesheet" href="style.css?v=20260911-file-suggestion">
<link rel="stylesheet" href="task-board.css?v=__CC_WEB_FRONTEND_ASSET_VERSION__">
<link rel="stylesheet" href="gitea-workflow.css?v=__CC_WEB_GITEA_WORKFLOW_ASSET_VERSION__">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/atom-one-dark.min.css">

View File

@@ -4425,8 +4425,9 @@ html[data-divider-time='hide'] .msg-bubble .agent-message-divider span {
border-radius: 12px;
box-shadow: 0 4px 20px rgba(45, 31, 20, 0.12);
padding: 6px;
min-width: 240px;
max-width: 320px;
width: min(560px, calc(100vw - 32px));
min-width: min(240px, calc(100vw - 32px));
max-width: none;
max-height: min(52vh, 360px);
overflow-y: auto;
overscroll-behavior: contain;
@@ -4486,6 +4487,23 @@ html[data-divider-time='hide'] .msg-bubble .agent-message-divider span {
text-overflow: ellipsis;
white-space: nowrap;
}
.cmd-item.file-suggestion .cmd-item-cmd {
color: var(--text-primary);
overflow: visible;
text-overflow: clip;
white-space: normal;
overflow-wrap: anywhere;
line-height: 1.35;
}
.cmd-item.file-suggestion .cmd-item-desc {
color: var(--text-muted);
font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace);
font-size: 12px;
white-space: normal;
overflow-wrap: anywhere;
line-height: 1.35;
max-height: 2.7em;
}
/* === Input Area === */
.input-area {
@@ -4932,7 +4950,7 @@ html[data-divider-time='hide'] .msg-bubble .agent-message-divider span {
gap: 10px;
}
.session-item-actions { display: flex; }
.cmd-menu { left: 10px; right: 10px; transform: none; min-width: auto; bottom: 72px; }
.cmd-menu { left: 10px; right: 10px; transform: none; width: auto; min-width: auto; bottom: 72px; }
.option-picker { left: 10px; right: 10px; transform: none; min-width: auto; bottom: 72px; }
.chat-header {
padding: 0 10px;

View File

@@ -0,0 +1,234 @@
'use strict';
const assert = require('node:assert');
const fs = require('node:fs');
const net = require('node:net');
const os = require('node:os');
const path = require('node:path');
const { spawn } = require('node:child_process');
const REPO_DIR = path.resolve(__dirname, '..');
const STYLE_PATH = path.join(REPO_DIR, 'public', 'style.css');
const GECKODRIVER = '/snap/firefox/current/usr/lib/firefox/geckodriver';
const FIREFOX = '/snap/firefox/current/usr/lib/firefox/firefox';
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function freePort() {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.once('error', reject);
server.listen(0, '127.0.0.1', () => {
const address = server.address();
server.close(() => resolve(address.port));
});
});
}
async function waitForPort(port, timeoutMs = 10_000) {
const started = Date.now();
while (Date.now() - started < timeoutMs) {
const ready = await new Promise((resolve) => {
const socket = net.createConnection({ host: '127.0.0.1', port });
socket.once('connect', () => {
socket.destroy();
resolve(true);
});
socket.once('error', () => resolve(false));
});
if (ready) return;
await sleep(50);
}
throw new Error(`等待 geckodriver 端口 ${port} 超时`);
}
async function stopChild(child) {
if (!child || child.exitCode !== null) return;
child.kill('SIGTERM');
await Promise.race([
new Promise((resolve) => child.once('exit', resolve)),
sleep(1_000),
]);
if (child.exitCode === null) child.kill('SIGKILL');
}
class FirefoxDriver {
constructor(port) {
this.port = port;
this.sessionId = '';
}
async request(method, endpoint, body) {
const response = await fetch(`http://127.0.0.1:${this.port}${endpoint}`, {
method,
headers: body === undefined ? {} : { 'content-type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
});
const payload = await response.json();
if (!response.ok || payload?.value?.error) {
throw new Error(`WebDriver ${method} ${endpoint}: ${JSON.stringify(payload.value || payload)}`);
}
return payload.value;
}
async start() {
const value = await this.request('POST', '/session', {
capabilities: {
alwaysMatch: {
browserName: 'firefox',
'moz:firefoxOptions': {
binary: FIREFOX,
args: ['-headless'],
prefs: {
'browser.shell.checkDefaultBrowser': false,
'browser.startup.homepage_override.mstone': 'ignore',
'media.prefers-reduced-motion': 1,
},
},
},
},
});
this.sessionId = value.sessionId;
}
async execute(script, args = []) {
return this.request('POST', `/session/${this.sessionId}/execute/sync`, { script, args });
}
async navigate(url) {
return this.request('POST', `/session/${this.sessionId}/url`, { url });
}
async setWindow(width, height) {
await this.request('POST', `/session/${this.sessionId}/window/rect`, { width, height });
return this.request('GET', `/session/${this.sessionId}/window/rect`);
}
async screenshot(targetPath) {
const base64 = await this.request('GET', `/session/${this.sessionId}/screenshot`);
fs.writeFileSync(targetPath, Buffer.from(base64, 'base64'));
}
async close() {
if (!this.sessionId) return;
try {
await this.request('DELETE', `/session/${this.sessionId}`);
} finally {
this.sessionId = '';
}
}
}
function fixtureHtml() {
const style = fs.readFileSync(STYLE_PATH, 'utf8');
return `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>${style}</style>
<style>
:root { --font-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
html, body { min-height: 100%; }
body { margin: 0; padding: 24px; background: var(--bg-primary); color: var(--text-primary); }
.fixture-stage { min-height: 460px; position: relative; }
#cmd-menu { position: relative; inset: auto; transform: none; display: block; margin: 0 auto; }
</style>
</head>
<body>
<main class="fixture-stage">
<div id="cmd-menu" class="cmd-menu">
<div class="cmd-item file-suggestion active" data-index="0" title="@01_设计书说明文档/wayfinder/README.md">
<span class="cmd-item-kind">File</span>
<span class="cmd-item-main">
<span class="cmd-item-cmd">README.md</span>
<span class="cmd-item-desc">@01_设计书说明文档/wayfinder/</span>
</span>
</div>
<div class="cmd-item file-suggestion" data-index="1" title="@01_设计书说明文档/wayfinder/hooks/">
<span class="cmd-item-kind">Dir</span>
<span class="cmd-item-main">
<span class="cmd-item-cmd">hooks/</span>
<span class="cmd-item-desc">@01_设计书说明文档/wayfinder/</span>
</span>
</div>
</div>
</main>
</body>
</html>`;
}
async function main() {
assert(fs.existsSync(GECKODRIVER), `缺少 geckodriver: ${GECKODRIVER}`);
assert(fs.existsSync(FIREFOX), `缺少 Firefox: ${FIREFOX}`);
const runtimeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-web-composer-file-browser-'));
const evidenceRoot = path.join(runtimeRoot, 'evidence');
fs.mkdirSync(evidenceRoot, { recursive: true });
const fixturePath = path.join(runtimeRoot, 'fixture.html');
fs.writeFileSync(fixturePath, fixtureHtml());
const driverPort = await freePort();
const geckodriver = spawn(GECKODRIVER, ['--port', String(driverPort), '-b', FIREFOX], {
cwd: runtimeRoot,
stdio: ['ignore', 'pipe', 'pipe'],
});
let driverStderr = '';
geckodriver.stderr.on('data', (chunk) => { driverStderr += chunk.toString(); });
const driver = new FirefoxDriver(driverPort);
const evidence = { screenshots: {}, audits: {} };
try {
await waitForPort(driverPort);
await driver.start();
await driver.navigate(`file://${fixturePath}`);
for (const viewport of [{ name: 'desktop', width: 1440, height: 900 }, { name: 'narrow', width: 375, height: 667 }]) {
const rect = await driver.setWindow(viewport.width, viewport.height);
const audit = await driver.execute(`
const menu = document.querySelector('#cmd-menu');
const menuRect = menu.getBoundingClientRect();
const items = Array.from(menu.querySelectorAll('.file-suggestion')).map((item) => {
const itemRect = item.getBoundingClientRect();
const main = item.querySelector('.cmd-item-main');
return {
primary: item.querySelector('.cmd-item-cmd')?.textContent.trim() || '',
secondary: item.querySelector('.cmd-item-desc')?.textContent.trim() || '',
right: itemRect.right,
fullyInsideMenu: itemRect.left >= menuRect.left && itemRect.right <= menuRect.right,
mainOverflow: main ? main.scrollWidth > main.clientWidth + 1 : false,
};
});
return {
viewport: { width: innerWidth, height: innerHeight },
window: arguments[0],
menu: { left: menuRect.left, right: menuRect.right, width: menuRect.width, scrollWidth: menu.scrollWidth },
items,
};
`, [rect]);
assert(audit.menu.right <= audit.viewport.width + 1, `${viewport.name} 菜单超出视口:${JSON.stringify(audit)}`);
assert(audit.menu.scrollWidth <= audit.menu.width + 1, `${viewport.name} 菜单出现横向溢出:${JSON.stringify(audit)}`);
assert.deepEqual(audit.items.map((item) => item.primary), ['README.md', 'hooks/']);
assert(audit.items.every((item) => item.secondary.includes('@01_设计书说明文档/wayfinder/')), `${viewport.name} 父路径不可见`);
assert(audit.items.every((item) => item.fullyInsideMenu && !item.mainOverflow), `${viewport.name} 候选内容溢出`);
evidence.audits[viewport.name] = audit;
const screenshotPath = path.join(evidenceRoot, `${viewport.name}-1440x900-or-375x667.png`);
await driver.screenshot(screenshotPath);
evidence.screenshots[viewport.name] = screenshotPath;
}
fs.writeFileSync(path.join(evidenceRoot, 'evidence.json'), JSON.stringify(evidence, null, 2));
console.log(JSON.stringify({ ok: true, evidenceRoot, ...evidence }, null, 2));
} catch (error) {
error.message += `\ngeckodriver stderr:\n${driverStderr.slice(-4_000)}`;
throw error;
} finally {
try { await driver.close(); } catch {}
await stopChild(geckodriver);
}
}
main().catch((error) => {
console.error(error.stack || error.message || String(error));
process.exitCode = 1;
});

View File

@@ -671,7 +671,7 @@ function assertFrontendSidebarCollapseContract() {
'Rich themes should provide isolated rail treatments on top of the shared semantic fallback'
);
assert(
indexSource.includes('style.css?v=20260818-goal-mode-label')
indexSource.includes('style.css?v=20260911-file-suggestion')
&& indexSource.includes('app.js?v=__CC_WEB_FRONTEND_ASSET_VERSION__'),
'Sidebar interaction assets should share the reviewed cache-busting version'
);
@@ -900,6 +900,42 @@ function assertFrontendComposerMcpContract() {
assert(menuStart >= 0 && menuEnd > menuStart, 'Frontend should define showCmdMenu before requestComposerSuggestions');
const menuBlock = source.slice(menuStart, menuEnd);
assert(/item\.kind\s*===\s*'mcp'/.test(menuBlock) && menuBlock.includes("'MCP'"), 'Composer menu should render MCP item labels');
assert(
source.includes('function getComposerFileSuggestionDisplay(item)')
&& menuBlock.includes('getComposerFileSuggestionDisplay(item)')
&& menuBlock.includes("' file-suggestion'")
&& menuBlock.includes('escapeHtmlAttr(fileDisplay.fullPath)'),
'Composer file suggestions should show the basename first while retaining the full path as a tooltip'
);
const fileDisplaySource = extractFunctionSource(source, 'getComposerFileSuggestionDisplay');
const fileDisplayApi = new Function(`${fileDisplaySource}; return getComposerFileSuggestionDisplay;`)();
const nestedFileDisplay = fileDisplayApi({
kind: 'file',
itemType: 'file',
name: '01_设计书说明文档/wayfinder/README.md',
label: '@01_设计书说明文档/wayfinder/README.md',
insertion: '@01_设计书说明文档/wayfinder/README.md',
});
assert(nestedFileDisplay.primaryText === 'README.md', 'Nested file suggestions should keep the final filename as the primary text');
assert(nestedFileDisplay.secondaryText === '@01_设计书说明文档/wayfinder/', 'Nested file suggestions should show the parent path as secondary text');
assert(nestedFileDisplay.fullPath === '@01_设计书说明文档/wayfinder/README.md', 'Nested file suggestions should retain the complete insertion path');
const nestedDirectoryDisplay = fileDisplayApi({
kind: 'file',
itemType: 'directory',
name: '01_设计书说明文档/wayfinder/hooks',
label: '@01_设计书说明文档/wayfinder/hooks/',
insertion: '@01_设计书说明文档/wayfinder/hooks/',
});
assert(nestedDirectoryDisplay.primaryText === 'hooks/', 'Nested directory suggestions should keep the final directory name and slash visible');
assert(nestedDirectoryDisplay.secondaryText === '@01_设计书说明文档/wayfinder/', 'Nested directory suggestions should show the parent path as secondary text');
const styleSource = fs.readFileSync(PUBLIC_STYLE_PATH, 'utf8');
assert(
/\.cmd-menu\s*\{[\s\S]*?width:\s*min\(560px,\s*calc\(100vw\s*-\s*32px\)\)/.test(styleSource)
&& styleSource.includes('.cmd-item.file-suggestion .cmd-item-cmd')
&& styleSource.includes('.cmd-item.file-suggestion .cmd-item-desc')
&& /\.cmd-menu\s*\{[^}]*left:\s*10px;[^}]*right:\s*10px;[^}]*width:\s*auto;/.test(styleSource),
'Composer menu should reserve enough width and style file path hierarchy'
);
const selectStart = source.indexOf('function selectComposerItemByIndex(index)');
const selectEnd = source.indexOf('\n function selectCmdMenuItem()', selectStart);
assert(selectStart >= 0 && selectEnd > selectStart, 'Frontend should define selectComposerItemByIndex before selectCmdMenuItem');
@@ -1154,7 +1190,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=20260818-goal-mode-label'), 'Plan progress CSS should use the current cache-busted URL');
assert(indexSource.includes('style.css?v=20260911-file-suggestion'), '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');
}
@@ -1270,7 +1306,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=20260818-goal-mode-label'), 'Theme bundle stylesheet should use the current cache-busted asset URL');
assert(indexSource.includes('style.css?v=20260911-file-suggestion'), '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');
}
@@ -1524,7 +1560,7 @@ function assertFrontendWastelandThemeContract() {
assert(contrast('#c9bda6', backgroundColor) >= 4.5, `Wasteland muted text should reach AA contrast on ${backgroundColor}`);
});
assert(indexSource.includes('style.css?v=20260818-goal-mode-label'), 'Wasteland stylesheet should share the cache-busted theme bundle URL');
assert(indexSource.includes('style.css?v=20260911-file-suggestion'), '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');
}
@@ -2554,7 +2590,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=20260818-goal-mode-label'), 'Goal label CSS should use the current cache-busted URL');
assert(indexSource.includes('style.css?v=20260911-file-suggestion'), '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');
@@ -5568,7 +5604,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=20260818-goal-mode-label')
assert(indexSource.includes('style.css?v=20260911-file-suggestion')
&& indexSource.includes('app.js?v=__CC_WEB_FRONTEND_ASSET_VERSION__'),
'Advanced search CSS and frontend script should share the reviewed cache-bust');
@@ -5685,7 +5721,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=20260818-goal-mode-label')
assert(indexSource.includes('style.css?v=20260911-file-suggestion')
&& 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');
@@ -6433,6 +6469,11 @@ async function main() {
console.log('Composer slash routing regression checks passed.');
return;
}
if (regressionTarget === 'composer-file-display') {
assertFrontendComposerMcpContract();
console.log('Composer file display regression checks passed.');
return;
}
if (regressionTarget === 'codexapp-unrouted-routing') {
assertCodexAppUnroutedNotificationRoutingContract();
console.log('Codex App unrouted routing regression checks passed.');
@@ -6847,6 +6888,12 @@ async function main() {
'command = "definitely-missing-mcp-command"',
].join('\n'));
fs.writeFileSync(path.join(codexInitCwd, 'context.txt'), 'Composer file context body.');
const nestedComposerRoot = path.join(codexInitCwd, '01_设计书说明文档', 'wayfinder');
const nestedComposerHooks = path.join(nestedComposerRoot, 'hooks', 'deep');
mkdirp(nestedComposerHooks);
fs.writeFileSync(path.join(nestedComposerRoot, 'README.md'), 'Nested composer file context body.');
fs.writeFileSync(path.join(nestedComposerRoot, 'hooks', 'index.md'), 'Nested composer directory file.');
fs.writeFileSync(path.join(nestedComposerHooks, 'notes.txt'), 'Three-level composer file context body.');
ws.send(JSON.stringify({ type: 'new_session', agent: 'codex', cwd: codexInitCwd, mode: 'plan' }));
const codexSession = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'codex' && msg.cwd === codexInitCwd);
assert(codexSession.mode === 'plan', 'Codex new_session should follow requested mode');
@@ -6964,6 +7011,35 @@ async function main() {
assert(fileComposer.items.some((item) => item.kind === 'file' && item.name === 'context.txt'), 'Composer file suggestions should include cwd file');
assert(!fileComposer.items.some((item) => item.kind === 'mcp'), 'Composer file suggestions should not include MCP tools');
ws.send(JSON.stringify({
type: 'composer_suggestions',
requestId: 'reg-file-nested',
trigger: '@',
query: '01_设计书说明文档/wayfinder/',
sessionId: codexSession.sessionId,
agent: 'codex',
}));
const nestedFileComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-file-nested');
const nestedFileSuggestion = nestedFileComposer.items.find((item) => item.kind === 'file' && item.name === '01_设计书说明文档/wayfinder/README.md');
const nestedDirectorySuggestion = nestedFileComposer.items.find((item) => item.kind === 'file' && item.itemType === 'directory' && item.name === '01_设计书说明文档/wayfinder/hooks');
assert(nestedFileSuggestion, 'Composer file suggestions should include a two-level nested file');
assert(nestedDirectorySuggestion, 'Composer file suggestions should include a two-level nested directory');
assert(nestedFileSuggestion.label === '@01_设计书说明文档/wayfinder/README.md' && nestedFileSuggestion.insertion === nestedFileSuggestion.label, 'Nested file suggestions should retain the complete insertion path');
assert(nestedDirectorySuggestion.label === '@01_设计书说明文档/wayfinder/hooks/' && nestedDirectorySuggestion.insertion === nestedDirectorySuggestion.label && nestedDirectorySuggestion.appendSpace === false, 'Nested directory suggestions should retain the trailing slash and continue completion');
ws.send(JSON.stringify({
type: 'composer_suggestions',
requestId: 'reg-file-deep',
trigger: '@',
query: '01_设计书说明文档/wayfinder/hooks/deep/',
sessionId: codexSession.sessionId,
agent: 'codex',
}));
const deepFileComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-file-deep');
const deepFileSuggestion = deepFileComposer.items.find((item) => item.kind === 'file' && item.name === '01_设计书说明文档/wayfinder/hooks/deep/notes.txt');
assert(deepFileSuggestion, 'Composer file suggestions should include a three-level nested file');
assert(deepFileSuggestion.label === '@01_设计书说明文档/wayfinder/hooks/deep/notes.txt' && deepFileSuggestion.insertion === deepFileSuggestion.label, 'Three-level file suggestions should retain the complete insertion path');
ws.send(JSON.stringify({
type: 'message',
text: '@shipit @quick-note @context.txt $regression-skill $project-skill run composer regression',