From 1c0e460dd143bfac8c530b537ba748925f6aab2f Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Fri, 27 Feb 2026 22:14:41 -0800 Subject: [PATCH] Add orphaned features management routes and UI integration (#819) * test(copilot): add edge case test for error with code field Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Changes from fix/bug-fixes-1-0 * refactor(auto-mode): enhance orphaned feature detection and improve project initialization - Updated detectOrphanedFeatures method to accept preloaded features, reducing redundant disk reads. - Improved project initialization by creating required directories and files in parallel for better performance. - Adjusted planning mode handling in UI components to clarify approval requirements for different modes. - Added refresh functionality for file editor tabs to ensure content consistency with disk state. These changes enhance performance, maintainability, and user experience across the application. * feat(orphaned-features): add orphaned features management routes and UI integration - Introduced new routes for managing orphaned features, including listing, resolving, and bulk resolving. - Updated the UI to include an Orphaned Features section in project settings and navigation. - Enhanced the execution service to support new orphaned feature functionalities. These changes improve the application's capability to handle orphaned features effectively, enhancing user experience and project management. * fix: Normalize line endings and resolve stale dirty states in file editor * chore: Update .gitignore and enhance orphaned feature handling - Added a blank line in .gitignore for better readability. - Introduced a hash to worktree paths in orphaned feature resolution to prevent conflicts. - Added validation for target branch existence during orphaned feature resolution. - Improved prompt formatting in execution service for clarity. - Enhanced error handling in project selector for project initialization failures. - Refactored orphaned features section to improve state management and UI responsiveness. These changes improve code maintainability and user experience when managing orphaned features. --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .gitignore | 1 + apps/server/src/routes/features/index.ts | 20 + .../server/src/routes/features/routes/list.ts | 2 +- .../src/routes/features/routes/orphaned.ts | 287 ++++++++ apps/server/src/services/auto-mode/compat.ts | 5 +- apps/server/src/services/auto-mode/facade.ts | 27 +- apps/server/src/services/execution-service.ts | 38 +- .../unit/lib/file-editor-store-logic.test.ts | 6 +- .../unit/services/execution-service.test.ts | 2 +- .../project-selector-with-options.tsx | 25 +- .../board-view/dialogs/add-feature-dialog.tsx | 8 +- .../board-view/dialogs/agent-output-modal.tsx | 24 +- .../dialogs/edit-feature-dialog.tsx | 8 +- .../board-view/dialogs/mass-edit-dialog.tsx | 4 +- .../shared/planning-mode-select.tsx | 4 +- .../components/editor-tabs.tsx | 19 +- .../file-editor-dirty-utils.ts | 12 +- .../file-editor-view/file-editor-view.tsx | 68 +- .../file-editor-view/use-file-editor-store.ts | 63 +- .../config/navigation.ts | 2 + .../hooks/use-project-settings-view.ts | 1 + .../orphaned-features-section.tsx | 658 ++++++++++++++++++ .../project-settings-view.tsx | 3 + apps/ui/src/hooks/use-auto-mode.ts | 49 +- apps/ui/src/lib/electron.ts | 48 ++ apps/ui/src/lib/http-api-client.ts | 75 +- apps/ui/src/lib/project-init.ts | 33 +- apps/ui/src/lib/query-client.ts | 12 +- .../agent-output-modal-responsive.spec.ts | 352 ++++++++++ .../features/success-log-contrast.spec.ts | 244 +++++++ apps/ui/tests/global-setup.ts | 8 +- apps/ui/tests/global-teardown.ts | 6 +- .../components/phase-model-selector.test.tsx | 4 +- apps/ui/tests/utils/cleanup-test-dirs.ts | 31 +- .../utils/components/responsive-modal.ts | 282 -------- apps/ui/tests/utils/helpers/temp-dir.ts | 23 - 36 files changed, 2048 insertions(+), 406 deletions(-) create mode 100644 apps/server/src/routes/features/routes/orphaned.ts create mode 100644 apps/ui/src/components/views/project-settings-view/orphaned-features-section.tsx create mode 100644 apps/ui/tests/features/responsive/agent-output-modal-responsive.spec.ts create mode 100644 apps/ui/tests/features/success-log-contrast.spec.ts delete mode 100644 apps/ui/tests/utils/components/responsive-modal.ts delete mode 100644 apps/ui/tests/utils/helpers/temp-dir.ts diff --git a/.gitignore b/.gitignore index d0331d7b..2672e420 100644 --- a/.gitignore +++ b/.gitignore @@ -76,6 +76,7 @@ test/board-bg-test-*/ test/edit-feature-test-*/ test/open-project-test-*/ + # Environment files (keep .example) .env .env.local diff --git a/apps/server/src/routes/features/index.ts b/apps/server/src/routes/features/index.ts index a4ea03b4..60ef9231 100644 --- a/apps/server/src/routes/features/index.ts +++ b/apps/server/src/routes/features/index.ts @@ -19,6 +19,11 @@ import { createAgentOutputHandler, createRawOutputHandler } from './routes/agent import { createGenerateTitleHandler } from './routes/generate-title.js'; import { createExportHandler } from './routes/export.js'; import { createImportHandler, createConflictCheckHandler } from './routes/import.js'; +import { + createOrphanedListHandler, + createOrphanedResolveHandler, + createOrphanedBulkResolveHandler, +} from './routes/orphaned.js'; export function createFeaturesRoutes( featureLoader: FeatureLoader, @@ -70,6 +75,21 @@ export function createFeaturesRoutes( validatePathParams('projectPath'), createConflictCheckHandler(featureLoader) ); + router.post( + '/orphaned', + validatePathParams('projectPath'), + createOrphanedListHandler(featureLoader, autoModeService) + ); + router.post( + '/orphaned/resolve', + validatePathParams('projectPath'), + createOrphanedResolveHandler(featureLoader, autoModeService) + ); + router.post( + '/orphaned/bulk-resolve', + validatePathParams('projectPath'), + createOrphanedBulkResolveHandler(featureLoader) + ); return router; } diff --git a/apps/server/src/routes/features/routes/list.ts b/apps/server/src/routes/features/routes/list.ts index 71d8a04a..46ff3b92 100644 --- a/apps/server/src/routes/features/routes/list.ts +++ b/apps/server/src/routes/features/routes/list.ts @@ -46,7 +46,7 @@ export function createListHandler( // Note: detectOrphanedFeatures handles errors internally and always resolves if (autoModeService) { autoModeService - .detectOrphanedFeatures(projectPath) + .detectOrphanedFeatures(projectPath, features) .then((orphanedFeatures) => { if (orphanedFeatures.length > 0) { logger.info( diff --git a/apps/server/src/routes/features/routes/orphaned.ts b/apps/server/src/routes/features/routes/orphaned.ts new file mode 100644 index 00000000..e44711be --- /dev/null +++ b/apps/server/src/routes/features/routes/orphaned.ts @@ -0,0 +1,287 @@ +/** + * POST /orphaned endpoint - Detect orphaned features (features with missing branches) + * POST /orphaned/resolve endpoint - Resolve an orphaned feature (delete, create-worktree, or move-to-branch) + * POST /orphaned/bulk-resolve endpoint - Resolve multiple orphaned features at once + */ + +import crypto from 'crypto'; +import path from 'path'; +import type { Request, Response } from 'express'; +import { FeatureLoader } from '../../../services/feature-loader.js'; +import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js'; +import { getErrorMessage, logError } from '../common.js'; +import { execGitCommand } from '../../../lib/git.js'; +import { deleteWorktreeMetadata } from '../../../lib/worktree-metadata.js'; +import { createLogger } from '@automaker/utils'; + +const logger = createLogger('OrphanedFeatures'); + +type ResolveAction = 'delete' | 'create-worktree' | 'move-to-branch'; +const VALID_ACTIONS: ResolveAction[] = ['delete', 'create-worktree', 'move-to-branch']; + +export function createOrphanedListHandler( + featureLoader: FeatureLoader, + autoModeService?: AutoModeServiceCompat +) { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath } = req.body as { projectPath: string }; + + if (!projectPath) { + res.status(400).json({ success: false, error: 'projectPath is required' }); + return; + } + + if (!autoModeService) { + res.status(500).json({ success: false, error: 'Auto-mode service not available' }); + return; + } + + const orphanedFeatures = await autoModeService.detectOrphanedFeatures(projectPath); + + res.json({ success: true, orphanedFeatures }); + } catch (error) { + logError(error, 'Detect orphaned features failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} + +export function createOrphanedResolveHandler( + featureLoader: FeatureLoader, + _autoModeService?: AutoModeServiceCompat +) { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath, featureId, action, targetBranch } = req.body as { + projectPath: string; + featureId: string; + action: ResolveAction; + targetBranch?: string | null; + }; + + if (!projectPath || !featureId || !action) { + res.status(400).json({ + success: false, + error: 'projectPath, featureId, and action are required', + }); + return; + } + + if (!VALID_ACTIONS.includes(action)) { + res.status(400).json({ + success: false, + error: `action must be one of: ${VALID_ACTIONS.join(', ')}`, + }); + return; + } + + const result = await resolveOrphanedFeature( + featureLoader, + projectPath, + featureId, + action, + targetBranch + ); + + if (!result.success) { + res.status(result.error === 'Feature not found' ? 404 : 500).json(result); + return; + } + + res.json(result); + } catch (error) { + logError(error, 'Resolve orphaned feature failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} + +interface BulkResolveResult { + featureId: string; + success: boolean; + action?: string; + error?: string; +} + +async function resolveOrphanedFeature( + featureLoader: FeatureLoader, + projectPath: string, + featureId: string, + action: ResolveAction, + targetBranch?: string | null +): Promise { + try { + const feature = await featureLoader.get(projectPath, featureId); + if (!feature) { + return { featureId, success: false, error: 'Feature not found' }; + } + + const missingBranch = feature.branchName; + + switch (action) { + case 'delete': { + if (missingBranch) { + try { + await deleteWorktreeMetadata(projectPath, missingBranch); + } catch { + // Non-fatal + } + } + const success = await featureLoader.delete(projectPath, featureId); + if (!success) { + return { featureId, success: false, error: 'Deletion failed' }; + } + logger.info(`Deleted orphaned feature ${featureId} (branch: ${missingBranch})`); + return { featureId, success: true, action: 'deleted' }; + } + + case 'create-worktree': { + if (!missingBranch) { + return { featureId, success: false, error: 'Feature has no branch name to recreate' }; + } + + const sanitizedName = missingBranch.replace(/[^a-zA-Z0-9_-]/g, '-'); + const hash = crypto.createHash('sha1').update(missingBranch).digest('hex').slice(0, 8); + const worktreesDir = path.join(projectPath, '.worktrees'); + const worktreePath = path.join(worktreesDir, `${sanitizedName}-${hash}`); + + try { + await execGitCommand(['worktree', 'add', '-b', missingBranch, worktreePath], projectPath); + } catch (error) { + const msg = getErrorMessage(error); + if (msg.includes('already exists')) { + try { + await execGitCommand(['worktree', 'add', worktreePath, missingBranch], projectPath); + } catch (innerError) { + return { + featureId, + success: false, + error: `Failed to create worktree: ${getErrorMessage(innerError)}`, + }; + } + } else { + return { featureId, success: false, error: `Failed to create worktree: ${msg}` }; + } + } + + logger.info( + `Created worktree for orphaned feature ${featureId} at ${worktreePath} (branch: ${missingBranch})` + ); + return { featureId, success: true, action: 'worktree-created' }; + } + + case 'move-to-branch': { + // Move the feature to a different branch (or clear branch to use main worktree) + const newBranch = targetBranch || null; + + // Validate that the target branch exists if one is specified + if (newBranch) { + try { + await execGitCommand(['rev-parse', '--verify', newBranch], projectPath); + } catch { + return { + featureId, + success: false, + error: `Target branch "${newBranch}" does not exist`, + }; + } + } + + await featureLoader.update(projectPath, featureId, { + branchName: newBranch, + status: 'pending', + }); + + // Clean up old worktree metadata + if (missingBranch) { + try { + await deleteWorktreeMetadata(projectPath, missingBranch); + } catch { + // Non-fatal + } + } + + const destination = newBranch ?? 'main worktree'; + logger.info( + `Moved orphaned feature ${featureId} to ${destination} (was: ${missingBranch})` + ); + return { featureId, success: true, action: 'moved' }; + } + } + } catch (error) { + return { featureId, success: false, error: getErrorMessage(error) }; + } +} + +export function createOrphanedBulkResolveHandler(featureLoader: FeatureLoader) { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath, featureIds, action, targetBranch } = req.body as { + projectPath: string; + featureIds: string[]; + action: ResolveAction; + targetBranch?: string | null; + }; + + if ( + !projectPath || + !featureIds || + !Array.isArray(featureIds) || + featureIds.length === 0 || + !action + ) { + res.status(400).json({ + success: false, + error: 'projectPath, featureIds (non-empty array), and action are required', + }); + return; + } + + if (!VALID_ACTIONS.includes(action)) { + res.status(400).json({ + success: false, + error: `action must be one of: ${VALID_ACTIONS.join(', ')}`, + }); + return; + } + + // Process sequentially for worktree creation (git operations shouldn't race), + // in parallel for delete/move-to-branch + const results: BulkResolveResult[] = []; + + if (action === 'create-worktree') { + for (const featureId of featureIds) { + const result = await resolveOrphanedFeature( + featureLoader, + projectPath, + featureId, + action, + targetBranch + ); + results.push(result); + } + } else { + const batchResults = await Promise.all( + featureIds.map((featureId) => + resolveOrphanedFeature(featureLoader, projectPath, featureId, action, targetBranch) + ) + ); + results.push(...batchResults); + } + + const successCount = results.filter((r) => r.success).length; + const failedCount = results.length - successCount; + + res.json({ + success: failedCount === 0, + resolvedCount: successCount, + failedCount, + results, + }); + } catch (error) { + logError(error, 'Bulk resolve orphaned features failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/services/auto-mode/compat.ts b/apps/server/src/services/auto-mode/compat.ts index 97fe19e8..ea911d9b 100644 --- a/apps/server/src/services/auto-mode/compat.ts +++ b/apps/server/src/services/auto-mode/compat.ts @@ -232,9 +232,10 @@ export class AutoModeServiceCompat { } async detectOrphanedFeatures( - projectPath: string + projectPath: string, + preloadedFeatures?: Feature[] ): Promise> { const facade = this.createFacade(projectPath); - return facade.detectOrphanedFeatures(); + return facade.detectOrphanedFeatures(preloadedFeatures); } } diff --git a/apps/server/src/services/auto-mode/facade.ts b/apps/server/src/services/auto-mode/facade.ts index 1093e62f..db4dccdc 100644 --- a/apps/server/src/services/auto-mode/facade.ts +++ b/apps/server/src/services/auto-mode/facade.ts @@ -463,9 +463,25 @@ export class AutoModeServiceFacade { (pPath, featureId, status) => featureStateManager.updateFeatureStatus(pPath, featureId, status), (pPath, featureId) => featureStateManager.loadFeature(pPath, featureId), - async (_feature) => { - // getPlanningPromptPrefixFn - planning prompts handled by AutoModeService - return ''; + async (feature) => { + // getPlanningPromptPrefixFn - select appropriate planning prompt based on feature's planningMode + if (!feature.planningMode || feature.planningMode === 'skip') { + return ''; + } + const prompts = await getPromptCustomization(settingsService, '[PlanningPromptPrefix]'); + const autoModePrompts = prompts.autoMode; + switch (feature.planningMode) { + case 'lite': + return feature.requirePlanApproval + ? autoModePrompts.planningLiteWithApproval + '\n\n' + : autoModePrompts.planningLite + '\n\n'; + case 'spec': + return autoModePrompts.planningSpec + '\n\n'; + case 'full': + return autoModePrompts.planningFull + '\n\n'; + default: + return ''; + } }, (pPath, featureId, summary) => featureStateManager.saveFeatureSummary(pPath, featureId, summary), @@ -1117,12 +1133,13 @@ export class AutoModeServiceFacade { /** * Detect orphaned features (features with missing branches) + * @param preloadedFeatures - Optional pre-loaded features to avoid redundant disk reads */ - async detectOrphanedFeatures(): Promise { + async detectOrphanedFeatures(preloadedFeatures?: Feature[]): Promise { const orphanedFeatures: OrphanedFeatureInfo[] = []; try { - const allFeatures = await this.featureLoader.getAll(this.projectPath); + const allFeatures = preloadedFeatures ?? (await this.featureLoader.getAll(this.projectPath)); const featuresWithBranches = allFeatures.filter( (f) => f.branchName && f.branchName.trim() !== '' ); diff --git a/apps/server/src/services/execution-service.ts b/apps/server/src/services/execution-service.ts index 9b87d30a..dc0555c5 100644 --- a/apps/server/src/services/execution-service.ts +++ b/apps/server/src/services/execution-service.ts @@ -108,16 +108,14 @@ export class ExecutionService { return firstLine.length <= 60 ? firstLine : firstLine.substring(0, 57) + '...'; } - buildFeaturePrompt( - feature: Feature, - taskExecutionPrompts: { - implementationInstructions: string; - playwrightVerificationInstructions: string; - } - ): string { + /** + * Build feature description section (without implementation instructions). + * Used when planning mode is active — the planning prompt provides its own instructions. + */ + buildFeatureDescription(feature: Feature): string { const title = this.extractTitleFromDescription(feature.description); - let prompt = `## Feature Implementation Task + let prompt = `## Feature Task **Feature ID:** ${feature.id} **Title:** ${title} @@ -146,6 +144,18 @@ ${feature.spec} prompt += `\n**Context Images Attached:**\n${feature.imagePaths.length} image(s) attached:\n${imagesList}\n`; } + return prompt; + } + + buildFeaturePrompt( + feature: Feature, + taskExecutionPrompts: { + implementationInstructions: string; + playwrightVerificationInstructions: string; + } + ): string { + let prompt = this.buildFeatureDescription(feature); + prompt += feature.skipTests ? `\n${taskExecutionPrompts.implementationInstructions}` : `\n${taskExecutionPrompts.implementationInstructions}\n\n${taskExecutionPrompts.playwrightVerificationInstructions}`; @@ -273,9 +283,15 @@ ${feature.spec} if (options?.continuationPrompt) { prompt = options.continuationPrompt; } else { - prompt = - (await this.getPlanningPromptPrefixFn(feature)) + - this.buildFeaturePrompt(feature, prompts.taskExecution); + const planningPrefix = await this.getPlanningPromptPrefixFn(feature); + if (planningPrefix) { + // Planning mode active: use planning instructions + feature description only. + // Do NOT include implementationInstructions — they conflict with the planning + // prompt's "DO NOT proceed with implementation until approval" directive. + prompt = planningPrefix + '\n\n' + this.buildFeatureDescription(feature); + } else { + prompt = this.buildFeaturePrompt(feature, prompts.taskExecution); + } if (feature.planningMode && feature.planningMode !== 'skip') { this.eventBus.emitAutoModeEvent('planning_started', { featureId: feature.id, diff --git a/apps/server/tests/unit/lib/file-editor-store-logic.test.ts b/apps/server/tests/unit/lib/file-editor-store-logic.test.ts index c355aaf0..7f6eabbd 100644 --- a/apps/server/tests/unit/lib/file-editor-store-logic.test.ts +++ b/apps/server/tests/unit/lib/file-editor-store-logic.test.ts @@ -287,15 +287,17 @@ describe('File editor dirty state logic', () => { expect(tab.isDirty).toBe(true); }); - it('should handle line ending differences as dirty', () => { + it('should treat CRLF and LF line endings as equivalent (not dirty)', () => { let tab = { content: 'line1\nline2', originalContent: 'line1\nline2', isDirty: false, }; + // CodeMirror normalizes \r\n to \n internally, so content that only + // differs by line endings should NOT be considered dirty. tab = updateTabContent(tab, 'line1\r\nline2'); - expect(tab.isDirty).toBe(true); + expect(tab.isDirty).toBe(false); }); it('should handle unicode content correctly', () => { diff --git a/apps/server/tests/unit/services/execution-service.test.ts b/apps/server/tests/unit/services/execution-service.test.ts index 0cc3ac01..0d976c9f 100644 --- a/apps/server/tests/unit/services/execution-service.test.ts +++ b/apps/server/tests/unit/services/execution-service.test.ts @@ -451,7 +451,7 @@ describe('execution-service.ts', () => { const callArgs = mockRunAgentFn.mock.calls[0]; expect(callArgs[0]).toMatch(/test.*project/); // workDir contains project expect(callArgs[1]).toBe('feature-1'); - expect(callArgs[2]).toContain('Feature Implementation Task'); + expect(callArgs[2]).toContain('Feature Task'); expect(callArgs[3]).toBeInstanceOf(AbortController); expect(callArgs[4]).toBe('/test/project'); // Model (index 6) should be resolved diff --git a/apps/ui/src/components/layout/sidebar/components/project-selector-with-options.tsx b/apps/ui/src/components/layout/sidebar/components/project-selector-with-options.tsx index 2c62c634..a1c6f8c5 100644 --- a/apps/ui/src/components/layout/sidebar/components/project-selector-with-options.tsx +++ b/apps/ui/src/components/layout/sidebar/components/project-selector-with-options.tsx @@ -17,6 +17,7 @@ import { import { cn } from '@/lib/utils'; import { formatShortcut, type ThemeMode, useAppStore } from '@/store/app-store'; import { initializeProject } from '@/lib/project-init'; +import { toast } from 'sonner'; import type { Project } from '@/lib/electron'; import { DropdownMenu, @@ -88,21 +89,21 @@ export function ProjectSelectorWithOptions({ const clearProjectHistory = useAppStore((s) => s.clearProjectHistory); const shortcuts = useKeyboardShortcutsConfig(); - // Wrap setCurrentProject to ensure .automaker is initialized before switching + // Wrap setCurrentProject to initialize .automaker in background while switching const setCurrentProjectWithInit = useCallback( - async (p: Project) => { + (p: Project) => { if (p.id === currentProject?.id) { return; } - try { - // Ensure .automaker directory structure exists before switching - await initializeProject(p.path); - } catch (error) { + // Fire-and-forget: initialize .automaker directory structure in background + // so the project switch is not blocked by filesystem operations + initializeProject(p.path).catch((error) => { console.error('Failed to initialize project during switch:', error); - // Continue with switch even if initialization fails - - // the project may already be initialized - } - // Defer project switch update to avoid synchronous render cascades. + toast.error('Failed to initialize project .automaker', { + description: error instanceof Error ? error.message : String(error), + }); + }); + // Switch project immediately for instant UI response startTransition(() => { setCurrentProject(p); }); @@ -131,8 +132,8 @@ export function ProjectSelectorWithOptions({ useProjectTheme(); const handleSelectProject = useCallback( - async (p: Project) => { - await setCurrentProjectWithInit(p); + (p: Project) => { + setCurrentProjectWithInit(p); setIsProjectPickerOpen(false); }, [setCurrentProjectWithInit, setIsProjectPickerOpen] diff --git a/apps/ui/src/components/views/board-view/dialogs/add-feature-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/add-feature-dialog.tsx index 19b2313e..92a61f67 100644 --- a/apps/ui/src/components/views/board-view/dialogs/add-feature-dialog.tsx +++ b/apps/ui/src/components/views/board-view/dialogs/add-feature-dialog.tsx @@ -297,9 +297,9 @@ export function AddFeatureDialog({ prefilledCategory, ]); - // Clear requirePlanApproval when planning mode is skip or lite + // Clear requirePlanApproval when planning mode is skip (lite supports approval) useEffect(() => { - if (planningMode === 'skip' || planningMode === 'lite') { + if (planningMode === 'skip') { setRequirePlanApproval(false); } }, [planningMode]); @@ -634,14 +634,14 @@ export function AddFeatureDialog({ id="add-feature-require-approval" checked={requirePlanApproval} onCheckedChange={(checked) => setRequirePlanApproval(!!checked)} - disabled={planningMode === 'skip' || planningMode === 'lite'} + disabled={planningMode === 'skip'} data-testid="add-feature-planning-require-approval-checkbox" />