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 ` + + + + + + + + +
+
+
+ File + + README.md + @01_设计书说明文档/wayfinder/ + +
+
+ Dir + + hooks/ + @01_设计书说明文档/wayfinder/ + +
+
+
+ +`; +} + +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',