From 58be11fe1dc4be7fd9c0e1ea9103cc74d1bbd6bc Mon Sep 17 00:00:00 2001 From: sifang Date: Wed, 1 Jul 2026 14:13:47 +0800 Subject: [PATCH] fix(wework): align Codex transcript rendering --- wework/src/App.tsx | 11 +- wework/src/components/chat/ChatInput.test.tsx | 109 +++++- .../components/chat/FileChangesCard.test.tsx | 59 ++- .../src/components/chat/FileChangesCard.tsx | 25 +- .../src/components/chat/MessageList.test.tsx | 41 +- wework/src/components/chat/MessageList.tsx | 74 +++- .../chat/RequestUserInputCard.test.tsx | 10 +- .../components/chat/RequestUserInputCard.tsx | 7 +- .../chat/blocks/ToolBlockItem.test.tsx | 52 +++ .../components/chat/blocks/ToolBlockItem.tsx | 42 +- .../chat/blocks/ToolBlocksDisplay.test.tsx | 241 ++++++++++++ .../chat/blocks/ToolBlocksDisplay.tsx | 141 ++++++- .../chat/blocks/toolBlockActivity.test.ts | 82 +++- .../chat/blocks/toolBlockActivity.ts | 369 +++++++++++++++++- .../components/chat/blocks/toolBlockKinds.ts | 57 ++- .../chat/composer/ModelSelector.tsx | 132 +++++-- .../chat/requestUserInputMessages.ts | 14 +- .../layout/DesktopWorkbenchLayout.test.tsx | 35 +- .../layout/DesktopWorkbenchLayout.tsx | 9 +- .../layout/MobileWorkbenchLayout.test.tsx | 6 +- .../layout/requestUserInputOverlay.test.ts | 6 +- .../layout/requestUserInputOverlay.ts | 10 +- .../layout/useDesktopSidebarCollapsed.ts | 21 + .../components/topnav/ChromeTitlebar.test.tsx | 8 +- .../src/components/topnav/ChromeTitlebar.tsx | 1 - .../workbench/WorkbenchProvider.test.tsx | 4 +- .../workbench/runtimePaneMessages.test.ts | 86 +++- .../features/workbench/runtimePaneMessages.ts | 8 +- wework/src/lib/codex-directives.test.ts | 56 +++ wework/src/lib/codex-directives.ts | 52 +++ 30 files changed, 1644 insertions(+), 124 deletions(-) create mode 100644 wework/src/lib/codex-directives.test.ts create mode 100644 wework/src/lib/codex-directives.ts diff --git a/wework/src/App.tsx b/wework/src/App.tsx index 833ef5a88c..b4f3cffc56 100644 --- a/wework/src/App.tsx +++ b/wework/src/App.tsx @@ -20,7 +20,10 @@ import { AppUpdateTitlebarButton } from '@/components/topnav/AppUpdateTitlebarBu import { LocalRuntimeInitializer } from '@/features/local-runtime/LocalRuntimeInitializer' import { CloudConnectionProvider } from '@/features/cloud-connection/CloudConnectionProvider' import { LocalExecutorCloudBridge } from '@/features/cloud-connection/LocalExecutorCloudBridge' -import { useDesktopSidebarCollapsed } from '@/components/layout/useDesktopSidebarCollapsed' +import { + requestDesktopSidebarToggle, + useDesktopSidebarCollapsed, +} from '@/components/layout/useDesktopSidebarCollapsed' import { DESKTOP_TOP_BAR_BUTTON_CLASS } from '@/components/layout/DesktopTopBar' import { useTranslation } from '@/hooks/useTranslation' import { cn } from '@/lib/utils' @@ -208,7 +211,11 @@ function TitlebarSidebarToggle() { -
+
{isWebSearchGroup ? ( ) : ( - row.blocks.map(block => ( - - )) + )}
@@ -439,17 +440,127 @@ function WebSearchActivityDetails({ blocks }: { blocks: ToolBlock[] }) { return } +function ToolActivityDetails({ + blocks, + onOpenWorkspaceFile, +}: { + blocks: ToolBlock[] + onOpenWorkspaceFile?: (path: string) => void +}) { + return ( + <> + {blocks.map(block => { + const item = getToolActivitySearchItem(block) + if (item) { + return + } + + const paths = getToolActivityFilePaths(block) + if (paths.length > 0) { + return paths.map(path => ( + + )) + } + + return ( + + ) + })} + + ) +} + +function CodeSearchActivityRow({ label }: { label: string }) { + return ( +
+ {label} +
+ ) +} + +function FileReadActivityRow({ + path, + onOpenWorkspaceFile, +}: { + path: string + onOpenWorkspaceFile?: (path: string) => void +}) { + const label = `Read ${basename(path)}` + const content = ( + + {label} + + ) + + if (onOpenWorkspaceFile) { + return ( + + ) + } + + return
{content}
+} + +function basename(path: string): string { + return path.split(/[\\/]/).filter(Boolean).pop() || path +} + function hasCommandBlocks(blocks: ToolBlock[]): boolean { return blocks.some(block => isCommandToolName(block.toolName)) } +function hasCodeSearchBlocks(blocks: ToolBlock[]): boolean { + return blocks.some(block => getToolActivityKind(block) === 'search') +} + function renderActivityGroupIcon(blocks: ToolBlock[]) { + if (hasCodeSearchBlocks(blocks)) { + return ( + + ) + } if (hasCommandBlocks(blocks)) { return } if (isGuidanceActivityGroup(blocks)) { return } + const kind = getToolActivityGroupKind(blocks) + if (kind === 'edit') { + return ( + + ) + } + if (kind === 'create' || kind === 'file') { + return ( + + ) + } return } diff --git a/wework/src/components/chat/blocks/toolBlockActivity.test.ts b/wework/src/components/chat/blocks/toolBlockActivity.test.ts index 4d4f56ba46..40885810e5 100644 --- a/wework/src/components/chat/blocks/toolBlockActivity.test.ts +++ b/wework/src/components/chat/blocks/toolBlockActivity.test.ts @@ -1,6 +1,11 @@ import { describe, expect, test } from 'vitest' import type { ProcessingBlock, ToolBlock } from '@/types/workbench' -import { buildProcessingDisplayRows, summarizeToolBlocks } from './toolBlockActivity' +import { + buildProcessingDisplayRows, + getToolActivityFilePaths, + getToolActivitySearchItem, + summarizeToolBlocks, +} from './toolBlockActivity' function tool( id: string, @@ -57,6 +62,52 @@ describe('toolBlockActivity', () => { ).toBe('已搜索代码') }) + test('extracts read file paths from shell read commands', () => { + expect( + getToolActivityFilePaths( + tool('nl-1', 'nl -ba wework/src/components/chat/blocks/toolBlockActivity.ts') + ) + ).toEqual(['wework/src/components/chat/blocks/toolBlockActivity.ts']) + + expect( + getToolActivityFilePaths( + tool( + 'sed-1', + '/bin/zsh -lc "sed -n \'1,120p\' wework/src/components/chat/blocks/toolBlockKinds.ts"' + ) + ) + ).toEqual(['wework/src/components/chat/blocks/toolBlockKinds.ts']) + }) + + test('extracts code search summaries from shell search commands', () => { + expect( + getToolActivitySearchItem({ + ...tool( + 'rg-1', + '/bin/zsh -lc "rg -n \'ToolBlockItem|toolBlock|file_changes|renderPayload|read.*file|command\'"' + ), + toolInput: { + command: + '/bin/zsh -lc "rg -n \'ToolBlockItem|toolBlock|file_changes|renderPayload|read.*file|command\'"', + workdir: '/Users/crystal/dev/git/Wegent/wework/src/components/chat/blocks', + }, + }) + ).toMatchObject({ + query: 'ToolBlockItem|toolBlock|file_changes|renderPayload|read.*file|command', + scope: 'blocks', + label: + 'Searched for ToolBlockItem|toolBlock|file_changes|renderPayload|read.*file|command in blocks', + }) + + expect( + getToolActivitySearchItem(tool('rg-2', "rg -n '已编辑|edited|edited_file|edit.*file' wework")) + ).toMatchObject({ + query: '已编辑|edited|edited_file|edit.*file', + scope: 'wework', + label: 'Searched for 已编辑|edited|edited_file|edit.*file in wework', + }) + }) + test('summarizes mid-turn user guidance activity', () => { expect( summarizeToolBlocks([ @@ -73,6 +124,35 @@ describe('toolBlockActivity', () => { ).toBe('已引导对话') }) + test('hides internal stdin polling tools from activity rows', () => { + const rows = buildProcessingDisplayRows([ + { + id: 'guidance-1', + turnId: 1, + type: 'tool', + toolName: 'conversation_guidance', + toolInput: { message: 'follow this file' }, + status: 'done', + createdAt: 1770000000000, + }, + { + id: 'stdin-1', + turnId: 1, + type: 'tool', + toolName: 'write_stdin', + toolInput: { session_id: 90870, chars: '' }, + status: 'done', + createdAt: 1770000000001, + }, + ]) + + expect(rows).toHaveLength(1) + expect(rows[0]).toMatchObject({ + type: 'activity_group', + label: '已引导对话', + }) + }) + test('classifies Claude multi-file edit tools as edit activity', () => { expect( summarizeToolBlocks([ diff --git a/wework/src/components/chat/blocks/toolBlockActivity.ts b/wework/src/components/chat/blocks/toolBlockActivity.ts index 476c411a6a..69516df06a 100644 --- a/wework/src/components/chat/blocks/toolBlockActivity.ts +++ b/wework/src/components/chat/blocks/toolBlockActivity.ts @@ -1,5 +1,6 @@ import type { ProcessingBlock, ToolBlock } from '@/types/workbench' import { + getFileInputPaths, getInputField, isCommandToolName, isFileCreateToolName, @@ -7,13 +8,28 @@ import { isFileReadToolName, isGuidanceToolName, isContextCompactionToolName, + isPatchApplyToolName, } from './toolBlockKinds' export type ProcessingDisplayRow = | { type: 'block'; id: string; block: ProcessingBlock } | { type: 'activity_group'; id: string; blocks: ToolBlock[]; label: string } -type ToolActivityKind = 'file' | 'search' | 'command' | 'create' | 'edit' | 'guidance' | 'tool' +export type ToolActivityKind = + | 'file' + | 'search' + | 'command' + | 'create' + | 'edit' + | 'guidance' + | 'tool' + +export interface ToolActivitySearchItem { + id: string + query: string + scope?: string + label: string +} interface ActivityStats { files: number @@ -30,6 +46,7 @@ interface ActivityStats { const SEARCH_TOOL_HINTS = ['search', 'grep', 'glob'] const SEARCH_COMMANDS = new Set(['rg', 'grep', 'find', 'fd', 'ls', 'tree', 'ag', 'ack']) const FILE_COMMANDS = new Set(['cat', 'sed', 'head', 'tail', 'wc', 'nl', 'stat', 'du', 'file']) +const HIDDEN_ACTIVITY_TOOLS = new Set(['write_stdin', 'functions.write_stdin']) export function buildProcessingDisplayRows( blocks: ProcessingBlock[], @@ -38,6 +55,7 @@ export function buildProcessingDisplayRows( const groupCompletedTools = options.groupCompletedTools ?? true const rows: ProcessingDisplayRow[] = [] let completedTools: ToolBlock[] = [] + const hasFileChangesBlock = blocks.some(block => block.type === 'file_changes') const flushCompletedTools = () => { if (completedTools.length === 0) return @@ -57,6 +75,14 @@ export function buildProcessingDisplayRows( continue } + if (isHiddenToolActivityBlock(block)) { + continue + } + + if (hasFileChangesBlock && isRedundantPatchApplyBlock(block)) { + continue + } + if (groupCompletedTools && block.type === 'tool' && isCompletedToolBlock(block)) { completedTools.push(block) continue @@ -129,7 +155,7 @@ function getActivityStats(blocks: ToolBlock[]): ActivityStats { ) } -function getToolActivityKind(block: ToolBlock): ToolActivityKind { +export function getToolActivityKind(block: ToolBlock): ToolActivityKind { const name = block.toolName.toLowerCase() if (isFileReadToolName(name)) return 'file' if (isFileCreateToolName(name)) return 'create' @@ -142,6 +168,55 @@ function getToolActivityKind(block: ToolBlock): ToolActivityKind { return 'tool' } +export function getToolActivityFilePaths(block: ToolBlock): string[] { + const name = block.toolName.toLowerCase() + if (isFileReadToolName(name)) return getFileInputPaths(block) + if (!isCommandToolName(name)) return [] + + const command = getInputField(block, 'command', 'cmd', 'commandLine') + if (getCommandActivityKind(command) !== 'file') return [] + return getReadCommandFilePaths(command) +} + +export function getToolActivitySearchItem(block: ToolBlock): ToolActivitySearchItem | undefined { + const name = block.toolName.toLowerCase() + if (isWebSearchToolName(name) || getToolActivityKind(block) !== 'search') return undefined + + if (isCommandToolName(name)) { + const command = getInputField(block, 'command', 'cmd', 'commandLine') + const summary = getSearchCommandSummary(command, getCommandWorkingDirectory(block)) + if (!summary) return undefined + return { + id: `${block.id}-code-search`, + query: summary.query, + scope: summary.scope, + label: formatSearchLabel(summary), + } + } + + const query = getInputField(block, 'query', 'pattern', 'search') + if (!query) return undefined + const scopePath = getInputField(block, 'path', 'directory', 'dir', 'root') + const scope = formatSearchScope(scopePath ? [scopePath] : [], getCommandWorkingDirectory(block)) + return { + id: `${block.id}-code-search`, + query, + scope, + label: formatSearchLabel({ query, scope }), + } +} + +export function getToolActivityGroupKind(blocks: ToolBlock[]): ToolActivityKind { + const kinds = blocks.map(getToolActivityKind) + if (kinds.length === 0) return 'tool' + + const primaryKinds = kinds.filter(kind => kind !== 'tool') + if (primaryKinds.length === 0) return 'tool' + + const firstKind = primaryKinds[0] + return primaryKinds.every(kind => kind === firstKind) ? firstKind : 'tool' +} + function getCommandActivityKind(command?: string): ToolActivityKind { const executable = getCommandExecutable(command) if (!executable) return 'command' @@ -152,6 +227,288 @@ function getCommandActivityKind(command?: string): ToolActivityKind { return 'command' } +function getReadCommandFilePaths(command?: string): string[] { + const words = splitShellWords(unwrapShellCommand(command ?? '')) + const executableIndex = getExecutableWordIndex(words) + const executable = words[executableIndex]?.split('/').pop()?.toLowerCase() + if (!executable || !FILE_COMMANDS.has(executable)) return [] + + const args = getFirstCommandSegment(words.slice(executableIndex + 1)) + if (executable === 'sed') return getSedInputPaths(args) + return getPathArguments(args, READ_COMMAND_OPTIONS_WITH_VALUES) +} + +function getSearchCommandSummary( + command: string | undefined, + cwd: string | undefined +): { query: string; scope?: string } | undefined { + const words = splitShellWords(unwrapShellCommand(command ?? '')) + const executableIndex = getExecutableWordIndex(words) + const executable = words[executableIndex]?.split('/').pop()?.toLowerCase() + if (!executable) return undefined + + const args = getFirstCommandSegment(words.slice(executableIndex + 1)) + if (executable === 'git') return getGitSearchCommandSummary(args, cwd) + if (executable === 'find') return getFindSearchCommandSummary(args, cwd) + if (PATTERN_SEARCH_COMMANDS.has(executable)) { + const parsed = getPatternSearchArguments(args) + if (!parsed?.query) return undefined + return { query: parsed.query, scope: formatSearchScope(parsed.scopes, cwd) } + } + return undefined +} + +const PATTERN_SEARCH_COMMANDS = new Set(['rg', 'grep', 'ag', 'ack', 'fd']) +const SEARCH_PATTERN_OPTIONS = new Set(['-e', '--regexp', '--pattern']) +const SEARCH_COMMAND_OPTIONS_WITH_VALUES = new Set([ + '-A', + '--after-context', + '-B', + '--before-context', + '-C', + '--context', + '-f', + '--file', + '-g', + '--glob', + '-j', + '--threads', + '-m', + '--max-count', + '--max-depth', + '--sort', + '--sortr', + '-t', + '--type', + '-T', + '--type-not', + '--colors', + '--color', + '--engine', + '--encoding', + '--ignore-file', + '--path-separator', +]) + +function getGitSearchCommandSummary( + args: string[], + cwd: string | undefined +): { query: string; scope?: string } | undefined { + const grepIndex = args.indexOf('grep') + if (grepIndex === -1) return undefined + + const grepArgs = args.slice(grepIndex + 1) + const separatorIndex = grepArgs.indexOf('--') + const searchArgs = separatorIndex === -1 ? grepArgs : grepArgs.slice(0, separatorIndex) + const pathspecs = separatorIndex === -1 ? [] : grepArgs.slice(separatorIndex + 1) + const parsed = getPatternSearchArguments(searchArgs) + if (!parsed?.query) return undefined + return { + query: parsed.query, + scope: formatSearchScope(pathspecs.length > 0 ? pathspecs : parsed.scopes, cwd), + } +} + +function getPatternSearchArguments(args: string[]): + | { + query: string + scopes: string[] + } + | undefined { + let query: string | undefined + let queryFromOption = false + const positional: string[] = [] + + for (let index = 0; index < args.length; index += 1) { + const arg = args[index] + if (!arg) continue + if (arg === '--') { + positional.push(...args.slice(index + 1).filter(isLikelyPathArgument)) + break + } + if (arg.startsWith('-')) { + const optionName = arg.includes('=') ? arg.slice(0, arg.indexOf('=')) : arg + if (SEARCH_PATTERN_OPTIONS.has(optionName)) { + if (!query) { + query = arg.includes('=') ? arg.slice(arg.indexOf('=') + 1) : args[index + 1] + queryFromOption = true + } + if (!arg.includes('=')) index += 1 + continue + } + if (!arg.includes('=') && SEARCH_COMMAND_OPTIONS_WITH_VALUES.has(optionName)) index += 1 + continue + } + positional.push(arg) + } + + query = query ?? positional[0] + if (!query) return undefined + return { query, scopes: queryFromOption ? positional : positional.slice(1) } +} + +function getFindSearchCommandSummary( + args: string[], + cwd: string | undefined +): { query: string; scope?: string } | undefined { + const expressionIndex = args.findIndex(arg => arg.startsWith('-') || arg === '(' || arg === '!') + const scopes = expressionIndex > 0 ? args.slice(0, expressionIndex) : [] + const expression = expressionIndex === -1 ? args : args.slice(expressionIndex) + const query = getFindExpressionQuery(expression) + if (!query) return undefined + return { query, scope: formatSearchScope(scopes, cwd) } +} + +const FIND_QUERY_OPTIONS = new Set(['-name', '-iname', '-path', '-ipath', '-regex', '-iregex']) + +function getFindExpressionQuery(expression: string[]): string | undefined { + for (let index = 0; index < expression.length; index += 1) { + const arg = expression[index] + if (FIND_QUERY_OPTIONS.has(arg)) return expression[index + 1] + } + return undefined +} + +function formatSearchLabel(summary: { query: string; scope?: string }): string { + return summary.scope + ? `Searched for ${summary.query} in ${summary.scope}` + : `Searched for ${summary.query}` +} + +function formatSearchScope(scopes: string[], cwd: string | undefined): string | undefined { + const scope = scopes.find(item => item && item !== '.' && item !== './') ?? cwd ?? scopes[0] + if (!scope) return undefined + return basename(scope) +} + +function getCommandWorkingDirectory(block: Pick): string | undefined { + return getInputField(block, 'cwd', 'workdir', 'workingDirectory') +} + +const READ_COMMAND_OPTIONS_WITH_VALUES = new Set([ + '-n', + '--lines', + '-c', + '--bytes', + '-m', + '--max-count', + '-t', + '--type', +]) + +function getSedInputPaths(args: string[]): string[] { + const nonOptionArgs = getPathArguments(args, new Set(['-e', '--expression', '-f', '--file'])) + if (nonOptionArgs.length <= 1) return [] + return nonOptionArgs.slice(1) +} + +function getPathArguments(args: string[], optionsWithValues: ReadonlySet): string[] { + const paths: string[] = [] + + for (let index = 0; index < args.length; index += 1) { + const arg = args[index] + if (!arg) continue + if (arg === '--') { + paths.push(...args.slice(index + 1).filter(isLikelyPathArgument)) + break + } + if (arg.startsWith('-')) { + const optionName = arg.includes('=') ? arg.slice(0, arg.indexOf('=')) : arg + if (!arg.includes('=') && optionsWithValues.has(optionName)) index += 1 + continue + } + if (isLikelyPathArgument(arg)) paths.push(arg) + } + + return Array.from(new Set(paths)) +} + +function isLikelyPathArgument(arg: string): boolean { + return arg.length > 0 && !arg.startsWith('>') && !arg.startsWith('<') +} + +function basename(path: string): string { + return path.split(/[\\/]/).filter(Boolean).pop() || path +} + +function getFirstCommandSegment(words: string[]): string[] { + const boundaryIndex = words.findIndex(word => COMMAND_BOUNDARIES.has(word)) + return boundaryIndex === -1 ? words : words.slice(0, boundaryIndex) +} + +const COMMAND_BOUNDARIES = new Set(['|', '||', '&&', ';']) + +function getExecutableWordIndex(words: string[]): number { + let index = 0 + if (words[index] === 'env') { + index += 1 + while (words[index]?.includes('=')) index += 1 + } + if (words[index] === 'sudo') index += 1 + return index +} + +function splitShellWords(command: string): string[] { + const words: string[] = [] + let current = '' + let quote: '"' | "'" | null = null + let escaped = false + + const pushCurrent = () => { + if (current.length === 0) return + words.push(current) + current = '' + } + + for (const char of command) { + if (escaped) { + current += char + escaped = false + continue + } + + if (char === '\\' && quote !== "'") { + escaped = true + continue + } + + if (quote) { + if (char === quote) { + quote = null + } else { + current += char + } + continue + } + + if (char === '"' || char === "'") { + quote = char + continue + } + + if (/\s/.test(char)) { + pushCurrent() + continue + } + + if (char === '|' || char === ';' || char === '&') { + pushCurrent() + const previous = words[words.length - 1] + if ((char === '|' || char === '&') && previous === char) { + words[words.length - 1] = `${char}${char}` + } else { + words.push(char) + } + continue + } + + current += char + } + + pushCurrent() + return words +} + function getCommandExecutable(command?: string): string { const innerCommand = unwrapShellCommand(command ?? '') const match = innerCommand.match( @@ -174,6 +531,14 @@ export function isContextCompactionToolBlock(block: ProcessingBlock): block is T return block.type === 'tool' && isContextCompactionToolName(block.toolName) } +function isRedundantPatchApplyBlock(block: ProcessingBlock): boolean { + return block.type === 'tool' && block.status === 'done' && isPatchApplyToolName(block.toolName) +} + +function isHiddenToolActivityBlock(block: ProcessingBlock): boolean { + return block.type === 'tool' && HIDDEN_ACTIVITY_TOOLS.has(block.toolName.toLowerCase()) +} + export function isWebSearchToolName(name: string): boolean { return name.toLowerCase() === 'web_search' } diff --git a/wework/src/components/chat/blocks/toolBlockKinds.ts b/wework/src/components/chat/blocks/toolBlockKinds.ts index 624d39665a..a04af709f8 100644 --- a/wework/src/components/chat/blocks/toolBlockKinds.ts +++ b/wework/src/components/chat/blocks/toolBlockKinds.ts @@ -22,6 +22,7 @@ const EDIT_TOOLS = new Set([ 'apply_patch', 'functions.apply_patch', ]) +const PATCH_APPLY_TOOLS = new Set(['apply_patch', 'functions.apply_patch']) const GUIDANCE_TOOLS = new Set(['conversation_guidance', 'user_guidance']) const CONTEXT_COMPACTION_TOOLS = new Set(['context_compaction', 'contextcompaction']) @@ -59,6 +60,10 @@ export function isFileEditToolName(name: string): boolean { return matchesToolName(name, EDIT_TOOLS) } +export function isPatchApplyToolName(name: string): boolean { + return matchesToolName(name, PATCH_APPLY_TOOLS) +} + export function isGuidanceToolName(name: string): boolean { return matchesToolName(name, GUIDANCE_TOOLS) } @@ -77,8 +82,19 @@ export function getInputField(block: Pick, ...keys: stri } export function getFileInputPath(block: Pick): string | undefined { - return getInputField( - block, + return getFileInputPaths(block)[0] +} + +export function getFileInputPaths(block: Pick): string[] { + const directPath = getDirectFileInputPath(block.toolInput) + if (directPath) return [directPath] + return getPatchFilePaths(block.toolInput) +} + +function getDirectFileInputPath(input: Record | undefined): string | undefined { + if (!input) return undefined + const directPath = getStringField( + input, 'file_path', 'filePath', 'filepath', @@ -90,4 +106,41 @@ export function getFileInputPath(block: Pick): string | 'notebook_path', 'notebookPath' ) + if (directPath) return directPath + + for (const nestedKey of ['input', 'arguments']) { + const nestedValue = input[nestedKey] + if (isRecord(nestedValue)) { + const nestedPath = getDirectFileInputPath(nestedValue) + if (nestedPath) return nestedPath + } + } + + return undefined +} + +function getPatchFilePaths(input: Record | undefined): string[] { + if (!input) return [] + const patchText = getStringField(input, 'patch', 'input', 'arguments', 'content') + if (!patchText) return [] + + const paths = new Set() + for (const line of patchText.split('\n')) { + const match = line.match(/^\*\*\* (?:Add|Delete|Update) File:\s*(.+)$/) + const path = match?.[1]?.trim() + if (path) paths.add(path) + } + return Array.from(paths) +} + +function getStringField(record: Record, ...keys: string[]): string | undefined { + for (const key of keys) { + const value = record[key] + if (typeof value === 'string') return value + } + return undefined +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) } diff --git a/wework/src/components/chat/composer/ModelSelector.tsx b/wework/src/components/chat/composer/ModelSelector.tsx index 58d3729331..669ee8809e 100644 --- a/wework/src/components/chat/composer/ModelSelector.tsx +++ b/wework/src/components/chat/composer/ModelSelector.tsx @@ -19,19 +19,21 @@ import { groupModelsByFamily, inferModelFamily, } from '@/lib/model-ui' +import { cn } from '@/lib/utils' import type { ModelCompatibilityDisabledReason, ModelOptions, UnifiedModel } from '@/types/api' import { useOutsideClick } from './useOutsideClick' const MAIN_MENU_WIDTH = 256 const SUBMENU_WIDTH = 288 -const SUBMENU_GAP = 8 +const SUBMENU_GAP = 0 const VIEWPORT_MARGIN = 16 const DESKTOP_MENU_VIEWPORT_TOP = 64 -const MAIN_MENU_TRIGGER_GAP = 20 +const MAIN_MENU_TRIGGER_GAP = 8 const MAIN_MENU_MAX_HEIGHT = 608 const SUBMENU_RIGHT_OFFSET = MAIN_MENU_WIDTH + SUBMENU_GAP const SUBMENU_MAX_HEIGHT = 448 const SUBMENU_VIEWPORT_VERTICAL_GAP = 128 +const DESKTOP_HIDDEN_CONTROL_IDS = new Set(['collaborationMode']) const FOCUSABLE_SELECTOR = 'button:not([disabled]), input:not([disabled]), [href], [tabindex]:not([tabindex="-1"])' type DesktopSubmenuTarget = @@ -76,6 +78,7 @@ export function ModelSelector({ const mobileCloseButtonRef = useRef(null) const handledOpenSignalRef = useRef(undefined) const familyButtonRefs = useRef(new Map()) + const controlButtonRefs = useRef(new Map()) const [open, setOpen] = useState(false) const [mobileQuery, setMobileQuery] = useState('') const [desktopMenuTop, setDesktopMenuTop] = useState(0) @@ -238,6 +241,13 @@ export function ModelSelector({ }, [updateSubmenuLayout] ) + const activateControl = useCallback( + (controlId: string, target?: HTMLElement | null) => { + setActiveDesktopSubmenu({ type: 'control', id: controlId }) + updateSubmenuLayout(target ?? controlButtonRefs.current.get(controlId) ?? null) + }, + [updateSubmenuLayout] + ) const clearDesktopSubmenu = useCallback(() => { setActiveDesktopSubmenu({ type: 'none' }) }, []) @@ -249,8 +259,16 @@ export function ModelSelector({ useLayoutEffect(() => { if (!open) return - updateSubmenuLayout(familyButtonRefs.current.get(displayedFamilyId) ?? null) - }, [activeDesktopSubmenu, displayedFamilyId, open, updateSubmenuLayout]) + if (activeDesktopSubmenu?.type === 'family') { + updateSubmenuLayout(familyButtonRefs.current.get(activeDesktopSubmenu.id) ?? null) + return + } + if (activeDesktopSubmenu?.type === 'control') { + updateSubmenuLayout(controlButtonRefs.current.get(activeDesktopSubmenu.id) ?? null) + return + } + updateSubmenuLayout(null) + }, [activeDesktopSubmenu, open, updateSubmenuLayout]) useEffect(() => { if (!open || !isMobile) return @@ -279,6 +297,17 @@ export function ModelSelector({ const controlsBelowModels = selectedModelControls.filter( control => control.placement === 'belowModels' ) + const desktopControlsAboveFamilies = controlsAboveFamilies.filter( + control => !DESKTOP_HIDDEN_CONTROL_IDS.has(control.id) + ) + const desktopControlsBelowModels = controlsBelowModels.filter( + control => !DESKTOP_HIDDEN_CONTROL_IDS.has(control.id) + ) + const activeControl = + activeDesktopSubmenu?.type === 'control' + ? desktopControlsBelowModels.find(control => control.id === activeDesktopSubmenu.id) + : undefined + const activeFamilySubmenuGroup = activeDesktopSubmenu?.type === 'family' ? activeGroup : undefined useLayoutEffect(() => { if (!open || isMobile) return @@ -288,8 +317,8 @@ export function ModelSelector({ return () => window.removeEventListener('resize', updateDesktopMenuLayout) }, [ activeGroup?.models.length, - controlsAboveFamilies.length, - controlsBelowModels.length, + desktopControlsAboveFamilies.length, + desktopControlsBelowModels.length, familyGroups.length, isMobile, open, @@ -316,9 +345,16 @@ export function ModelSelector({ }) }, [activeGroup, normalizedMobileQuery, resolveControlLabel, selectedModelOptions]) - function renderControlSection(control: ModelControlConfig) { + function renderControlSection( + control: ModelControlConfig, + { clearSubmenuOnHover = true }: { clearSubmenuOnHover?: boolean } = {} + ) { return ( -
+
{control.labelKey ? t(control.labelKey) : control.label}
@@ -334,7 +370,7 @@ export function ModelSelector({ key={option.value} type="button" data-testid={`model-control-${control.id}-${option.value}`} - onFocus={clearDesktopSubmenu} + onFocus={clearSubmenuOnHover ? clearDesktopSubmenu : undefined} onClick={() => handleSelectModelOption(control.id, option.value)} className="flex min-h-8 w-full items-center gap-3 rounded-lg px-3 py-1.5 text-left text-sm text-text-primary hover:bg-muted" > @@ -359,6 +395,49 @@ export function ModelSelector({ ) } + function renderControlMenuItem(control: ModelControlConfig) { + const active = + activeDesktopSubmenu?.type === 'control' && activeDesktopSubmenu.id === control.id + + return ( + + ) + } + + function getFamilyMenuLabel(group: (typeof familyGroups)[number]): string { + const modelForLabel = + selectedModel && selectedFamily === group.config.id ? selectedModel : group.models[0] + return modelForLabel + ? getModelDisplayLabel(modelForLabel, {}, resolveControlLabel) || group.config.label + : group.config.label + } + function renderAutomaticReasoningSection() { return (
@@ -658,10 +737,8 @@ export function ModelSelector({ {open && !isMobile && (
- {(controlsAboveFamilies.length > 0 || activeGroup) && ( + {(desktopControlsAboveFamilies.length > 0 || activeGroup) && ( <>
- {controlsAboveFamilies.map(renderControlSection)} + {desktopControlsAboveFamilies.map(control => renderControlSection(control))} {!supportsReasoningControl && renderAutomaticReasoningSection()}
@@ -706,24 +783,33 @@ export function ModelSelector({ : 'text-text-secondary hover:bg-muted hover:text-text-primary', ].join(' ')} > - {group.config.label} + {getFamilyMenuLabel(group)} ) })}
- {controlsBelowModels.length > 0 && ( + {desktopControlsBelowModels.length > 0 && ( <>
-
- {controlsBelowModels.map(renderControlSection)} +
+ {desktopControlsBelowModels.map(renderControlMenuItem)}
)}
- {activeGroup ? ( + {activeControl ? ( +
+ {renderControlSection(activeControl, { clearSubmenuOnHover: false })} +
+ ) : activeFamilySubmenuGroup ? (
- {activeGroup.models.map(model => { + {activeFamilySubmenuGroup.models.map(model => { const selected = model.name === selectedModel?.name && model.type === selectedModel?.type const modelDisabled = Boolean(model.compatibilityDisabled) @@ -786,7 +872,7 @@ export function ModelSelector({ })}
- ) : ( + ) : activeDesktopSubmenu?.type === 'family' ? (
{t('workbench.no_models')}
- )} + ) : null}
)}