diff --git a/dist-exe/cc-web-bun-linux-x64-baseline.tar.gz b/dist-exe/cc-web-bun-linux-x64-baseline.tar.gz
index c95448b..433b2e9 100644
Binary files a/dist-exe/cc-web-bun-linux-x64-baseline.tar.gz and b/dist-exe/cc-web-bun-linux-x64-baseline.tar.gz differ
diff --git a/public/app.js b/public/app.js
index d54fb57..50c3ebb 100644
--- a/public/app.js
+++ b/public/app.js
@@ -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 `
+ 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 `
${kindLabel}
- ${escapeHtml(item.label || item.name || item.insertion || '')}
- ${escapeHtml(item.description || item.title || '')}
+ ${escapeHtml(primaryText)}
+ ${escapeHtml(secondaryText)}
`;
}).join('');
diff --git a/public/index.html b/public/index.html
index 4de09de..ca771d0 100644
--- a/public/index.html
+++ b/public/index.html
@@ -23,7 +23,7 @@
document.documentElement.dataset.dividerTime = dividerTime;
})();
-
+
diff --git a/public/style.css b/public/style.css
index 21e6ee2..1566575 100644
--- a/public/style.css
+++ b/public/style.css
@@ -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;
diff --git a/scripts/composer-file-display-browser.js b/scripts/composer-file-display-browser.js
new file mode 100644
index 0000000..80076ea
--- /dev/null
+++ b/scripts/composer-file-display-browser.js
@@ -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 `
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+}
+
+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;
+});
+
diff --git a/scripts/regression.js b/scripts/regression.js
index 12bf889..24285a4 100644
--- a/scripts/regression.js
+++ b/scripts/regression.js
@@ -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',