diff --git a/docs/superpowers/specs/2026-04-14-ai-inspection-professional-badge-toggle-design.md b/docs/superpowers/specs/2026-04-14-ai-inspection-professional-badge-toggle-design.md new file mode 100644 index 000000000..59bf67eae --- /dev/null +++ b/docs/superpowers/specs/2026-04-14-ai-inspection-professional-badge-toggle-design.md @@ -0,0 +1,75 @@ +# AI Inspection Professional Badge Toggle Design + +**Date:** 2026-04-14 + +**Goal** + +Enable the status badge in AI inspection professional mode to toggle the corresponding inspection item selection. Clicking `已纳入` or `未纳入` should behave the same as toggling that item from the left inspection sidebar. + +**Scope** + +- Only change the professional-mode setup view in the AI inspection modal. +- Only make the per-item status badge clickable. +- Keep the rest of the card read-only. +- Preserve existing behavior in normal mode, running state, and report state. + +**Current Behavior** + +- In professional mode, the right-side detail panel shows each inspection item as a card. +- The badge text reflects selection state with `已纳入` or `未纳入`. +- The badge is display-only and cannot change selection. +- Selection changes are currently driven from the left sidebar and the normal-mode setup view. + +**Desired Behavior** + +- In professional mode setup, clicking the status badge toggles the item selection. +- When the item is selected, clicking `已纳入` removes it from the run. +- When the item is not selected, clicking `未纳入` includes it in the run. +- The right card state, left sidebar state, and summary metrics stay synchronized because they continue to share the same `selectedItems` state. + +**Design** + +## UI structure + +- Replace the badge-only `span` in `InspectionSetupView.tsx` with a semantic `button`. +- Keep the visual style close to the existing badge so the change is behavioral, not visual redesign. +- Add a small hover/focus treatment consistent with current token usage. + +## Data flow + +- `AIInspectionModal.tsx` remains the owner of `selectedItems`. +- Pass a new `onToggleItem(categoryId, itemId)` callback into `InspectionSetupView`. +- The callback updates `selectedItems` with the same add/remove semantics already used by the sidebar item toggle. + +## Interaction rules + +- The badge is clickable only in setup view professional mode. +- No change to whole-card click behavior. +- No extra confirmation dialog. +- No i18n changes are required because the existing labels already match the desired states. + +## Accessibility + +- Use a real `button` so keyboard users can trigger the change. +- Keep the visible label as the current included/skipped text. +- Add an `aria-pressed` state tied to item selection. + +## Testing + +- Add a test in `AIInspectionModal.test.tsx` that enters professional mode, clicks the right-side status badge, and verifies: + - the right-side item state toggles from included to skipped or the reverse + - the selection summary updates + - the left sidebar selection count remains synchronized + +**Files** + +- Modify `src/features/ai-assistant/components/AIInspectionModal.tsx` +- Modify `src/features/ai-assistant/components/InspectionSetupView.tsx` +- Modify `src/features/ai-assistant/components/AIInspectionModal.test.tsx` + +**Out of Scope** + +- Making the entire inspection item card clickable +- Changing normal-mode selection behavior +- Refactoring shared selection helpers between the sidebar and setup view +- Any report-view interaction changes diff --git a/src/app/AppLayout.tsx b/src/app/AppLayout.tsx index 64d8e3756..0654267ab 100644 --- a/src/app/AppLayout.tsx +++ b/src/app/AppLayout.tsx @@ -3,6 +3,7 @@ * Main application layout with Header and workspace area */ import React, { useRef, useCallback, useEffect, useMemo, useState, lazy, Suspense } from 'react'; +import type { RootState } from '@react-three/fiber'; import { useShallow } from 'zustand/react/shallow'; import { Header } from './components/Header'; import { IkToolPanel } from './components/IkToolPanel'; @@ -11,6 +12,7 @@ import { ConnectedDocumentLoadingOverlay } from './components/ConnectedDocumentL import { FileDropOverlay } from './components/FileDropOverlay'; import { ImportPreparationOverlay } from './components/ImportPreparationOverlay'; import { SnapshotDialog } from './components/SnapshotDialog'; +import { resolveSnapshotCaptureAction } from './components/snapshot-preview/resolveSnapshotCaptureAction'; import { loadBridgeCreateModalModule, loadCollisionOptimizationDialogModule, @@ -66,7 +68,11 @@ import { } from '@/store'; import type { BridgeJoint, RobotFile, UrdfJoint, UrdfLink } from '@/types'; import { translations } from '@/shared/i18n'; -import type { SnapshotCaptureOptions } from '@/shared/components/3d'; +import { + captureWorkspaceCameraSnapshot, + type SnapshotCaptureAction, + type SnapshotCaptureOptions, +} from '@/shared/components/3d'; import { normalizeMergedAppMode } from '@/shared/utils/appMode'; import { hasSingleDofJoints } from '@/shared/utils/jointTypes'; import { isAssetLibraryOnlyFormat, ROBOT_IMPORT_ACCEPT_ATTRIBUTE } from '@/shared/utils'; @@ -77,6 +83,7 @@ import { resolveDocumentLoadingOverlayTargetFileName } from './utils/documentLoa import { clearIkDragHelperSelection } from './utils/ikDragSession'; import { resolveIkToolSelectionState } from './utils/ikToolSelectionState'; import { resolveAssemblyRootComponentSelectionAvailability } from './utils/assemblyRootComponentSelection'; +import type { SnapshotPreviewSession } from './components/snapshot-preview/types'; interface ProModeRoundtripSession { baselineSnapshot: string; @@ -165,6 +172,7 @@ export function AppLayout({ sidebarTab, sourceCodeAutoApply, setViewOption, + groundPlaneOffset, } = useUIStore( useShallow((state) => ({ appMode: state.appMode, @@ -176,6 +184,7 @@ export function AppLayout({ sidebarTab: state.sidebarTab, sourceCodeAutoApply: state.sourceCodeAutoApply, setViewOption: state.setViewOption, + groundPlaneOffset: state.groundPlaneOffset, })), ); @@ -343,6 +352,7 @@ export function AppLayout({ const snapshotActionRef = useRef< ((options?: Partial) => Promise) | null >(null); + const viewerCanvasStateRef = useRef(null); const transformPendingRef = useRef(false); const pendingUsdAssemblyFileRef = useRef(null); const proModeRoundtripSessionRef = useRef(null); @@ -353,6 +363,9 @@ export function AppLayout({ const [isCollisionOptimizerOpen, setIsCollisionOptimizerOpen] = useState(false); const [isSnapshotDialogOpen, setIsSnapshotDialogOpen] = useState(false); const [isSnapshotCapturing, setIsSnapshotCapturing] = useState(false); + const [snapshotPreviewSession, setSnapshotPreviewSession] = + useState(null); + const snapshotPreviewCaptureActionRef = useRef(null); const [isIkToolPanelOpen, setIsIkToolPanelOpen] = useState(false); const [shouldRenderBridgeModal, setShouldRenderBridgeModal] = useState(false); const [bridgePreview, setBridgePreview] = useState(null); @@ -801,9 +814,78 @@ export function AppLayout({ [handleCodeChange, sourceCodeDocuments], ); + const viewerSourceFile = useMemo( + () => + getViewerSourceFile({ + selectedFile, + shouldRenderAssembly, + workspaceSourceFile: workspaceViewerMjcfSourceFile, + }), + [selectedFile, shouldRenderAssembly, workspaceViewerMjcfSourceFile], + ); + + const handleCloseSnapshotDialog = useCallback(() => { + setIsSnapshotDialogOpen(false); + setSnapshotPreviewSession(null); + snapshotPreviewCaptureActionRef.current = null; + }, []); + + const handleSnapshotPreviewCaptureActionChange = useCallback( + (action: SnapshotCaptureAction | null) => { + snapshotPreviewCaptureActionRef.current = action; + }, + [], + ); + const handleSnapshot = useCallback(() => { + const viewerCanvasState = viewerCanvasStateRef.current; + const cameraSnapshot = viewerCanvasState + ? captureWorkspaceCameraSnapshot(viewerCanvasState) + : null; + const viewportAspectRatio = + cameraSnapshot?.aspectRatio ?? + (viewerCanvasState?.size.width && viewerCanvasState.size.height + ? viewerCanvasState.size.width / viewerCanvasState.size.height + : 16 / 9); + + snapshotPreviewCaptureActionRef.current = null; + setSnapshotPreviewSession({ + theme, + cameraSnapshot, + viewportAspectRatio, + robotName: previewFileName ?? (viewerRobot.name || 'robot'), + robot: viewerRobot, + assets: viewerAssets, + availableFiles, + urdfContent: urdfContentForViewer, + viewerSourceFormat, + sourceFilePath: viewerSourceFilePath, + sourceFile: viewerSourceFile, + jointAngleState, + jointMotionState, + showVisual, + isMeshPreview: selectedFile?.format === 'mesh', + viewerReloadKey, + groundPlaneOffset, + }); setIsSnapshotDialogOpen(true); - }, []); + }, [ + availableFiles, + groundPlaneOffset, + jointAngleState, + jointMotionState, + previewFileName, + selectedFile?.format, + showVisual, + theme, + urdfContentForViewer, + viewerAssets, + viewerReloadKey, + viewerRobot, + viewerSourceFile, + viewerSourceFilePath, + viewerSourceFormat, + ]); const handleSetIkDragActive = useCallback( (active: boolean) => { @@ -866,15 +948,20 @@ export function AppLayout({ const handleCaptureSnapshot = useCallback( async (options: SnapshotCaptureOptions) => { - if (!snapshotActionRef.current) { + const captureAction = resolveSnapshotCaptureAction({ + liveCaptureAction: snapshotActionRef.current, + frozenPreviewCaptureAction: snapshotPreviewCaptureActionRef.current, + preferFrozenPreviewCapture: Boolean(snapshotPreviewSession), + }); + + if (!captureAction) { showToast(t.snapshotFailed, 'info'); return; } try { setIsSnapshotCapturing(true); - await snapshotActionRef.current(options); - setIsSnapshotDialogOpen(false); + await captureAction(options); } catch (error) { console.error('Snapshot failed:', error); showToast(t.snapshotFailed, 'info'); @@ -882,7 +969,7 @@ export function AppLayout({ setIsSnapshotCapturing(false); } }, - [showToast, t], + [handleCloseSnapshotDialog, showToast, snapshotPreviewSession, t], ); const { @@ -1044,6 +1131,9 @@ export function AppLayout({ showVisual={showVisual} setShowVisual={handleSetShowVisual} snapshotAction={snapshotActionRef} + onCanvasCreated={(state) => { + viewerCanvasStateRef.current = state; + }} showToolbar={viewConfig.showToolbar} setShowToolbar={(show) => setViewConfig((prev) => ({ ...prev, showToolbar: show }))} showOptionsPanel={viewConfig.showOptionsPanel} @@ -1056,11 +1146,7 @@ export function AppLayout({ urdfContent={urdfContentForViewer} viewerSourceFormat={viewerSourceFormat} sourceFilePath={viewerSourceFilePath} - sourceFile={getViewerSourceFile({ - selectedFile, - shouldRenderAssembly, - workspaceSourceFile: workspaceViewerMjcfSourceFile, - })} + sourceFile={viewerSourceFile} onRobotDataResolved={handleRobotDataResolved} onDocumentLoadEvent={handleViewerDocumentLoadEvent} onRuntimeRobotLoaded={handleViewerRuntimeRobotLoaded} @@ -1134,7 +1220,9 @@ export function AppLayout({ isOpen={isSnapshotDialogOpen} isCapturing={isSnapshotCapturing} lang={lang} - onClose={() => setIsSnapshotDialogOpen(false)} + previewSession={snapshotPreviewSession} + onPreviewCaptureActionChange={handleSnapshotPreviewCaptureActionChange} + onClose={handleCloseSnapshotDialog} onCapture={handleCaptureSnapshot} /> diff --git a/src/app/components/SnapshotDialog.test.tsx b/src/app/components/SnapshotDialog.test.tsx index 14d2237fc..672e2a7c6 100644 --- a/src/app/components/SnapshotDialog.test.tsx +++ b/src/app/components/SnapshotDialog.test.tsx @@ -88,3 +88,408 @@ test('SnapshotDialog reuses the segmented surface tone for AA choices', async () dom.window.close(); } }); + +test('SnapshotDialog opens with a wider default width so compression labels fit without truncation', async () => { + const dom = installDom(); + const container = dom.window.document.getElementById('root'); + assert.ok(container, 'root container should exist'); + + const root = createRoot(container); + + try { + await act(async () => { + root.render( + React.createElement(SnapshotDialog, { + isOpen: true, + isCapturing: false, + lang: 'en', + onClose: () => {}, + onCapture: () => {}, + }), + ); + }); + + const windowRoot = container.firstElementChild as HTMLElement | null; + assert.ok(windowRoot, 'snapshot dialog should render a draggable window root'); + assert.equal( + windowRoot.style.width, + '620px', + 'snapshot dialog should default to the wider width that can show compression labels cleanly', + ); + } finally { + await act(async () => { + root.unmount(); + }); + dom.window.close(); + } +}); + +test('SnapshotDialog defaults the grid toggle to enabled with the visible Grid label', async () => { + const dom = installDom(); + const container = dom.window.document.getElementById('root'); + assert.ok(container, 'root container should exist'); + + const root = createRoot(container); + + try { + await act(async () => { + root.render( + React.createElement(SnapshotDialog, { + isOpen: true, + isCapturing: false, + lang: 'en', + onClose: () => {}, + onCapture: () => {}, + }), + ); + }); + + const labelTexts = Array.from(container.querySelectorAll('div')) + .map((element) => element.textContent?.trim()) + .filter(Boolean); + assert.ok( + labelTexts.includes('Grid'), + 'snapshot dialog should expose the positive Grid label instead of Hide Grid', + ); + assert.ok( + !labelTexts.includes('Hide Grid'), + 'snapshot dialog should no longer render the old negative grid label', + ); + + const gridSwitch = container.querySelector('[role="switch"]'); + assert.ok(gridSwitch, 'snapshot dialog should render the grid switch'); + assert.equal( + gridSwitch?.getAttribute('aria-checked'), + 'true', + 'grid should be visible by default when the dialog opens', + ); + assert.equal( + gridSwitch?.getAttribute('aria-label'), + 'Grid', + 'grid switch aria label should match the visible positive label', + ); + assert.match( + gridSwitch?.parentElement?.className ?? '', + /\bjustify-start\b/, + 'grid switch row should align the control to the left edge of its field', + ); + } finally { + await act(async () => { + root.unmount(); + }); + dom.window.close(); + } +}); + +test('SnapshotDialog renders the live preview state and frozen-view hint', async () => { + const dom = installDom(); + const container = dom.window.document.getElementById('root'); + assert.ok(container, 'root container should exist'); + + const root = createRoot(container); + + try { + await act(async () => { + root.render( + React.createElement(SnapshotDialog, { + isOpen: true, + isCapturing: false, + lang: 'en', + onClose: () => {}, + onCapture: () => {}, + previewState: { + status: 'refreshing', + imageUrl: 'blob:preview', + aspectRatio: 16 / 9, + }, + }), + ); + }); + + const previewImage = container.querySelector('img[alt="Snapshot live preview"]'); + assert.ok(previewImage, 'snapshot dialog should render the latest preview image'); + assert.equal(previewImage?.getAttribute('src'), 'blob:preview'); + + const textContent = container.textContent ?? ''; + assert.match(textContent, /Live Preview/); + assert.match(textContent, /Based on the view when this dialog opened/); + assert.match(textContent, /Updating preview/); + assert.match(textContent, /Final export quality still follows the selected resolution/); + } finally { + await act(async () => { + root.unmount(); + }); + dom.window.close(); + } +}); + +test('SnapshotDialog keeps the live preview inside the scrollable content area', async () => { + const dom = installDom(); + const container = dom.window.document.getElementById('root'); + assert.ok(container, 'root container should exist'); + + const root = createRoot(container); + + try { + await act(async () => { + root.render( + React.createElement(SnapshotDialog, { + isOpen: true, + isCapturing: false, + lang: 'en', + onClose: () => {}, + onCapture: () => {}, + previewState: { + status: 'ready', + imageUrl: 'blob:preview', + aspectRatio: 16 / 9, + }, + }), + ); + }); + + const scrollableContent = container.querySelector('.overflow-y-auto'); + assert.ok(scrollableContent, 'snapshot dialog should keep a scrollable content region'); + assert.match( + scrollableContent.textContent ?? '', + /Live Preview/, + 'preview content should stay inside the scrollable body instead of competing with the footer', + ); + } finally { + await act(async () => { + root.unmount(); + }); + dom.window.close(); + } +}); + +test('SnapshotDialog keeps the preview in natural document flow instead of stretching it with filler spacing', async () => { + const dom = installDom(); + const container = dom.window.document.getElementById('root'); + assert.ok(container, 'root container should exist'); + + const root = createRoot(container); + + try { + await act(async () => { + root.render( + React.createElement(SnapshotDialog, { + isOpen: true, + isCapturing: false, + lang: 'en', + onClose: () => {}, + onCapture: () => {}, + previewState: { + status: 'ready', + imageUrl: 'blob:preview', + aspectRatio: 16 / 9, + }, + }), + ); + }); + + const scrollableContent = container.querySelector('.overflow-y-auto') as HTMLElement | null; + assert.ok(scrollableContent, 'snapshot dialog should render the scrollable body'); + assert.doesNotMatch( + scrollableContent.className, + /\bflex-col\b/, + 'scrollable body should rely on the dialog size instead of stretching content with a flex column', + ); + assert.doesNotMatch( + scrollableContent.lastElementChild?.className ?? '', + /\bmt-auto\b/, + 'preview card should keep its natural position directly after the scene section', + ); + } finally { + await act(async () => { + root.unmount(); + }); + dom.window.close(); + } +}); + +test('SnapshotDialog auto-fits its default height to the rendered content when the viewport allows it', async () => { + const dom = installDom(); + const container = dom.window.document.getElementById('root'); + assert.ok(container, 'root container should exist'); + + const root = createRoot(container); + const originalInnerHeightDescriptor = Object.getOwnPropertyDescriptor(dom.window, 'innerHeight'); + const originalScrollHeightDescriptor = Object.getOwnPropertyDescriptor( + dom.window.HTMLElement.prototype, + 'scrollHeight', + ); + const originalOffsetHeightDescriptor = Object.getOwnPropertyDescriptor( + dom.window.HTMLElement.prototype, + 'offsetHeight', + ); + + Object.defineProperty(dom.window, 'innerHeight', { + value: 900, + configurable: true, + }); + Object.defineProperty(dom.window.HTMLElement.prototype, 'scrollHeight', { + configurable: true, + get() { + return this.className.includes('overflow-y-auto') ? 596 : 0; + }, + }); + Object.defineProperty(dom.window.HTMLElement.prototype, 'offsetHeight', { + configurable: true, + get() { + if (this.className.includes('h-10')) { + return 40; + } + if (this.className.includes('border-t')) { + return 46; + } + return 0; + }, + }); + + try { + await act(async () => { + root.render( + React.createElement(SnapshotDialog, { + isOpen: true, + isCapturing: false, + lang: 'en', + onClose: () => {}, + onCapture: () => {}, + previewState: { + status: 'ready', + imageUrl: 'blob:preview', + aspectRatio: 16 / 9, + }, + }), + ); + }); + + const windowRoot = container.firstElementChild as HTMLElement | null; + assert.ok(windowRoot, 'snapshot dialog should render a draggable window root'); + assert.equal( + windowRoot.style.height, + '682px', + 'snapshot dialog should shrink to the content-fitted desktop height instead of keeping a fixed tall shell', + ); + } finally { + if (originalInnerHeightDescriptor) { + Object.defineProperty(dom.window, 'innerHeight', originalInnerHeightDescriptor); + } + if (originalScrollHeightDescriptor) { + Object.defineProperty( + dom.window.HTMLElement.prototype, + 'scrollHeight', + originalScrollHeightDescriptor, + ); + } else { + delete (dom.window.HTMLElement.prototype as { scrollHeight?: number }).scrollHeight; + } + if (originalOffsetHeightDescriptor) { + Object.defineProperty( + dom.window.HTMLElement.prototype, + 'offsetHeight', + originalOffsetHeightDescriptor, + ); + } else { + delete (dom.window.HTMLElement.prototype as { offsetHeight?: number }).offsetHeight; + } + await act(async () => { + root.unmount(); + }); + dom.window.close(); + } +}); + +test('SnapshotDialog caps its auto-fitted height to the available viewport when the content is taller', async () => { + const dom = installDom(); + const container = dom.window.document.getElementById('root'); + assert.ok(container, 'root container should exist'); + + const root = createRoot(container); + const originalInnerHeightDescriptor = Object.getOwnPropertyDescriptor(dom.window, 'innerHeight'); + const originalScrollHeightDescriptor = Object.getOwnPropertyDescriptor( + dom.window.HTMLElement.prototype, + 'scrollHeight', + ); + const originalOffsetHeightDescriptor = Object.getOwnPropertyDescriptor( + dom.window.HTMLElement.prototype, + 'offsetHeight', + ); + + Object.defineProperty(dom.window, 'innerHeight', { + value: 680, + configurable: true, + }); + Object.defineProperty(dom.window.HTMLElement.prototype, 'scrollHeight', { + configurable: true, + get() { + return this.className.includes('overflow-y-auto') ? 700 : 0; + }, + }); + Object.defineProperty(dom.window.HTMLElement.prototype, 'offsetHeight', { + configurable: true, + get() { + if (this.className.includes('h-10')) { + return 40; + } + if (this.className.includes('border-t')) { + return 46; + } + return 0; + }, + }); + + try { + await act(async () => { + root.render( + React.createElement(SnapshotDialog, { + isOpen: true, + isCapturing: false, + lang: 'en', + onClose: () => {}, + onCapture: () => {}, + previewState: { + status: 'ready', + imageUrl: 'blob:preview', + aspectRatio: 16 / 9, + }, + }), + ); + }); + + const windowRoot = container.firstElementChild as HTMLElement | null; + assert.ok(windowRoot, 'snapshot dialog should render a draggable window root'); + assert.equal( + windowRoot.style.height, + '656px', + 'snapshot dialog should clamp the fitted height to the current viewport limit', + ); + } finally { + if (originalInnerHeightDescriptor) { + Object.defineProperty(dom.window, 'innerHeight', originalInnerHeightDescriptor); + } + if (originalScrollHeightDescriptor) { + Object.defineProperty( + dom.window.HTMLElement.prototype, + 'scrollHeight', + originalScrollHeightDescriptor, + ); + } else { + delete (dom.window.HTMLElement.prototype as { scrollHeight?: number }).scrollHeight; + } + if (originalOffsetHeightDescriptor) { + Object.defineProperty( + dom.window.HTMLElement.prototype, + 'offsetHeight', + originalOffsetHeightDescriptor, + ); + } else { + delete (dom.window.HTMLElement.prototype as { offsetHeight?: number }).offsetHeight; + } + await act(async () => { + root.unmount(); + }); + dom.window.close(); + } +}); diff --git a/src/app/components/SnapshotDialog.tsx b/src/app/components/SnapshotDialog.tsx index f365dea7c..71a17b19c 100644 --- a/src/app/components/SnapshotDialog.tsx +++ b/src/app/components/SnapshotDialog.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { Camera, X } from 'lucide-react'; import { Button, @@ -12,9 +12,12 @@ import { DraggableWindow } from '@/shared/components'; import { useDraggableWindow } from '@/shared/hooks'; import { DEFAULT_SNAPSHOT_CAPTURE_OPTIONS, + type SnapshotCaptureAction, type SnapshotCaptureOptions, } from '@/shared/components/3d'; import { translations, type Language } from '@/shared/i18n'; +import { SnapshotPreviewRenderer } from './snapshot-preview/SnapshotPreviewRenderer'; +import type { SnapshotDialogPreviewState, SnapshotPreviewSession } from './snapshot-preview/types'; const SNAPSHOT_RESOLUTION_OPTIONS = [ { value: '1280', label: '720p' }, @@ -29,12 +32,52 @@ const PANEL_SECTION_CLASS_NAME = const FIELD_ROW_CLASS_NAME = 'grid grid-cols-[78px_minmax(0,1fr)] items-center gap-2'; const FIELD_LABEL_CLASS_NAME = 'truncate text-[10px] font-medium tracking-[0.01em] text-text-secondary'; +const SNAPSHOT_DIALOG_DEFAULT_SIZE = { + width: 620, + height: 690, +} as const; +const SNAPSHOT_DIALOG_MIN_SIZE = { + width: 500, + height: 420, +} as const; +const SNAPSHOT_DIALOG_HEADER_HEIGHT = 40; +const SNAPSHOT_DIALOG_VIEWPORT_MARGIN = 24; +const SNAPSHOT_DIALOG_VIEWPORT_MIN_HEIGHT = 320; + +const clamp = (value: number, min: number, max: number) => { + if (max < min) { + return min; + } + return Math.max(min, Math.min(max, value)); +}; + +const resolveSnapshotDialogHeight = ({ + scrollContentHeight, + footerHeight, + viewportHeight, +}: { + scrollContentHeight: number; + footerHeight: number; + viewportHeight: number; +}) => { + const viewportLimit = Math.max( + SNAPSHOT_DIALOG_VIEWPORT_MIN_HEIGHT, + viewportHeight - SNAPSHOT_DIALOG_VIEWPORT_MARGIN, + ); + const minHeight = Math.min(SNAPSHOT_DIALOG_MIN_SIZE.height, viewportLimit); + const naturalHeight = SNAPSHOT_DIALOG_HEADER_HEIGHT + footerHeight + scrollContentHeight; + return clamp(naturalHeight, minHeight, viewportLimit); +}; + interface SnapshotDialogProps { isOpen: boolean; isCapturing: boolean; lang: Language; onClose: () => void; onCapture: (options: SnapshotCaptureOptions) => Promise | void; + previewSession?: SnapshotPreviewSession | null; + previewState?: SnapshotDialogPreviewState; + onPreviewCaptureActionChange?: (action: SnapshotCaptureAction | null) => void; } function SnapshotSection({ title, children }: { title: string; children: React.ReactNode }) { @@ -63,6 +106,9 @@ export function SnapshotDialog({ lang, onClose, onCapture, + previewSession = null, + previewState, + onPreviewCaptureActionChange, }: SnapshotDialogProps) { const t = translations[lang]; const [resolutionPreset, setResolutionPreset] = useState( @@ -81,11 +127,18 @@ export function SnapshotDialog({ DEFAULT_SNAPSHOT_CAPTURE_OPTIONS.backgroundStyle, ); const [hideGrid, setHideGrid] = useState(DEFAULT_SNAPSHOT_CAPTURE_OPTIONS.hideGrid); + const [internalPreviewState, setInternalPreviewState] = useState({ + status: 'idle', + imageUrl: null, + aspectRatio: previewSession?.viewportAspectRatio ?? 16 / 9, + }); + const scrollBodyRef = useRef(null); + const footerRef = useRef(null); const windowState = useDraggableWindow({ isOpen, - defaultSize: { width: 560, height: 332 }, - minSize: { width: 500, height: 308 }, + defaultSize: SNAPSHOT_DIALOG_DEFAULT_SIZE, + minSize: SNAPSHOT_DIALOG_MIN_SIZE, centerOnMount: true, enableMinimize: false, enableMaximize: false, @@ -98,7 +151,7 @@ export function SnapshotDialog({ }, }); - useEffect(() => { + useLayoutEffect(() => { if (!isOpen) { return; } @@ -113,7 +166,46 @@ export function SnapshotDialog({ setDofMode(DEFAULT_SNAPSHOT_CAPTURE_OPTIONS.dofMode); setBackgroundStyle(DEFAULT_SNAPSHOT_CAPTURE_OPTIONS.backgroundStyle); setHideGrid(DEFAULT_SNAPSHOT_CAPTURE_OPTIONS.hideGrid); - }, [isOpen]); + setInternalPreviewState({ + status: 'idle', + imageUrl: null, + aspectRatio: previewSession?.viewportAspectRatio ?? 16 / 9, + }); + }, [isOpen, previewSession?.viewportAspectRatio]); + + useLayoutEffect(() => { + if (!isOpen) { + return; + } + + const scrollBody = scrollBodyRef.current; + const footer = footerRef.current; + + if (!scrollBody || !footer) { + return; + } + + const nextHeight = resolveSnapshotDialogHeight({ + scrollContentHeight: scrollBody.scrollHeight, + footerHeight: footer.offsetHeight, + viewportHeight: window.innerHeight, + }); + + windowState.setSize((currentSize) => + currentSize.height === nextHeight ? currentSize : { ...currentSize, height: nextHeight }, + ); + }, [ + isOpen, + lang, + internalPreviewState.aspectRatio, + internalPreviewState.imageUrl, + internalPreviewState.status, + previewState?.imageUrl, + previewState?.status, + previewSession?.viewportAspectRatio, + previewState?.aspectRatio, + windowState.setSize, + ]); useEffect(() => { if (imageFormat === 'jpeg' && backgroundStyle === 'transparent') { @@ -256,7 +348,7 @@ export function SnapshotDialog({ shadow: lang === 'zh' ? '阴影' : 'Shadow', ground: lang === 'zh' ? '地面' : 'Ground', dof: lang === 'zh' ? '景深' : 'DoF', - grid: lang === 'zh' ? '隐藏网格' : 'Hide Grid', + grid: lang === 'zh' ? '网格' : 'Grid', }), [lang], ); @@ -277,6 +369,17 @@ export function SnapshotDialog({ ? 80 : 60 : 'lossless'; + const effectivePreviewState = previewState ?? internalPreviewState; + const previewStatusText = + effectivePreviewState.status === 'loading' || effectivePreviewState.status === 'idle' + ? t.snapshotPreviewLoading + : effectivePreviewState.status === 'refreshing' + ? t.snapshotPreviewRefreshing + : effectivePreviewState.status === 'error' + ? t.snapshotPreviewFailed + : t.snapshotPreviewReady; + const previewAspectRatio = + effectivePreviewState.aspectRatio > 0 ? effectivePreviewState.aspectRatio : 16 / 9; if (!isOpen) { return null; @@ -319,7 +422,7 @@ export function SnapshotDialog({ closeTitle={t.close} >
-
+
@@ -434,47 +537,112 @@ export function SnapshotDialog({ setHideGrid(!checked)} disabled={isCapturing} ariaLabel={t.snapshotHideGrid} - className="w-full justify-end" + className="w-full justify-start" />
-
-
-
-
- {captureSummary} +
+
+
+
+ {t.snapshotPreviewTitle} +
+
+ {t.snapshotPreviewFrozenView} +
+
+
+ {previewStatusText} +
-
- - + {effectivePreviewState.imageUrl ? ( +
+ {t.snapshotPreviewAlt} + {effectivePreviewState.status === 'refreshing' ? ( +
+
+ {t.snapshotPreviewRefreshing} +
+
+ ) : null} +
+ ) : ( +
+ {effectivePreviewState.status === 'error' + ? t.snapshotPreviewFailed + : t.snapshotPreviewLoading} +
+ )} +
+
+ +
+
+
{captureSummary}
+
{t.snapshotPreviewQualityHint}
+
+ {effectivePreviewState.status === 'error' ? ( +
+ {t.snapshotPreviewRetryingHint} +
+ ) : null}
+ +
+
+ + +
+
+ {!previewState && previewSession ? ( + + ) : null} ); } diff --git a/src/app/components/UnifiedViewer.tsx b/src/app/components/UnifiedViewer.tsx index a6eb40a36..3e81fda6f 100644 --- a/src/app/components/UnifiedViewer.tsx +++ b/src/app/components/UnifiedViewer.tsx @@ -1,4 +1,5 @@ import React, { useEffect } from 'react'; +import type { RootState } from '@react-three/fiber'; import type { Group as ThreeGroup, Object3D as ThreeObject3D } from 'three'; import type { AppMode, @@ -82,6 +83,7 @@ interface UnifiedViewerProps { showVisual?: boolean; setShowVisual?: (show: boolean) => void; snapshotAction?: React.RefObject; + onCanvasCreated?: (state: RootState) => void; showToolbar?: boolean; setShowToolbar?: (show: boolean) => void; showOptionsPanel?: boolean; @@ -167,6 +169,7 @@ export const UnifiedViewer = React.memo( showVisual, setShowVisual, snapshotAction, + onCanvasCreated, showToolbar = true, setShowToolbar, showOptionsPanel = true, @@ -550,6 +553,7 @@ export const UnifiedViewer = React.memo( renderKey={`viewer:stable:${viewerReloadKey}`} containerRef={viewerController.containerRef} snapshotAction={snapshotAction} + onCreated={onCanvasCreated} onPointerDownCapture={handleWorkspacePointerDownCapture} onPointerMissed={handleViewerPointerMissed} onMouseMove={viewerController.handleMouseMove} diff --git a/src/app/components/snapshot-preview/SnapshotPreviewRenderer.tsx b/src/app/components/snapshot-preview/SnapshotPreviewRenderer.tsx new file mode 100644 index 000000000..1cf3ee55a --- /dev/null +++ b/src/app/components/snapshot-preview/SnapshotPreviewRenderer.tsx @@ -0,0 +1,273 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + STUDIO_ENVIRONMENT_INTENSITY, + WORKSPACE_CANVAS_BACKGROUND, + WorkspaceCanvas, + type SnapshotCaptureAction, + type SnapshotCaptureOptions, + type SnapshotPreviewAction, + useWorkspaceCanvasTheme, +} from '@/shared/components/3d'; +import { translations, type Language } from '@/shared/i18n'; +import { useViewerController, resolveDefaultViewerToolMode } from '@/features/editor'; +import { buildUnifiedViewerResourceScopes } from '@/app/utils/unifiedViewerResourceScopes'; +import { resolveSnapshotPreviewSurfaceSize } from '@/shared/components/3d'; +import { ViewerSceneConnector } from '../unified-viewer/ViewerSceneConnector'; +import { toSnapshotPreviewActionState } from './previewActionState'; + +import type { SnapshotDialogPreviewState, SnapshotPreviewSession } from './types'; + +interface SnapshotPreviewRendererProps { + isOpen: boolean; + lang: Language; + session: SnapshotPreviewSession | null; + options: SnapshotCaptureOptions; + onStateChange: (state: SnapshotDialogPreviewState) => void; + onCaptureActionChange?: (action: SnapshotCaptureAction | null) => void; +} + +export function SnapshotPreviewRenderer({ + isOpen, + lang, + session, + options, + onStateChange, + onCaptureActionChange, +}: SnapshotPreviewRendererProps) { + const t = translations[lang]; + const previousViewerResourceScopeRef = useRef< + ReturnType['viewerResourceScope'] | null + >(null); + const previewRequestIdRef = useRef(0); + const previewTimerRef = useRef(null); + const previewUrlRef = useRef(null); + const previewInFlightRef = useRef(false); + const queuedPreviewRef = useRef<{ + requestId: number; + options: SnapshotCaptureOptions; + aspectRatio: number; + } | null>(null); + const [previewAction, setPreviewAction] = useState(null); + const effectiveTheme = useWorkspaceCanvasTheme(session?.theme ?? 'light'); + const surfaceSize = useMemo( + () => resolveSnapshotPreviewSurfaceSize(session?.viewportAspectRatio ?? 16 / 9), + [session?.viewportAspectRatio], + ); + const handlePreviewActionChange = useCallback((nextAction: SnapshotPreviewAction | null) => { + setPreviewAction(toSnapshotPreviewActionState(nextAction)); + }, []); + + const controller = useViewerController({ + active: false, + showJointPanel: false, + jointAngleState: session?.jointAngleState, + jointMotionState: session?.jointMotionState, + showVisual: session?.showVisual ?? true, + groundPlaneOffset: session?.groundPlaneOffset ?? 0, + groundPlaneOffsetReadOnly: true, + defaultToolMode: resolveDefaultViewerToolMode(session?.sourceFile?.format), + toolModeScopeKey: session?.sourceFile?.name + ? `snapshot-preview:${session.sourceFile.name}` + : 'snapshot-preview:inline', + }); + + const viewerResourceScope = useMemo(() => { + const next = buildUnifiedViewerResourceScopes({ + activePreview: undefined, + urdfContent: session?.urdfContent ?? '', + sourceFilePath: session?.sourceFilePath, + sourceFile: session?.sourceFile, + assets: session?.assets ?? {}, + availableFiles: session?.availableFiles ?? [], + viewerRobotLinks: session?.robot.links, + viewerRobotMaterials: session?.robot.materials, + previousViewerResourceScope: previousViewerResourceScopeRef.current, + }); + previousViewerResourceScopeRef.current = next.viewerResourceScope; + return next.viewerResourceScope; + }, [ + session?.assets, + session?.availableFiles, + session?.robot.links, + session?.robot.materials, + session?.sourceFile, + session?.sourceFilePath, + session?.urdfContent, + ]); + + useEffect(() => { + return () => { + previewRequestIdRef.current += 1; + if (previewTimerRef.current !== null) { + window.clearTimeout(previewTimerRef.current); + previewTimerRef.current = null; + } + if (previewUrlRef.current) { + URL.revokeObjectURL(previewUrlRef.current); + previewUrlRef.current = null; + } + queuedPreviewRef.current = null; + previewInFlightRef.current = false; + }; + }, []); + + const executePreviewRequest = useCallback( + (requestId: number, nextOptions: SnapshotCaptureOptions, aspectRatio: number) => { + if (!previewAction) { + return; + } + + if (previewInFlightRef.current) { + queuedPreviewRef.current = { + requestId, + options: nextOptions, + aspectRatio, + }; + return; + } + + previewInFlightRef.current = true; + previewAction(nextOptions) + .then((result) => { + if (requestId !== previewRequestIdRef.current) { + return; + } + + const nextUrl = URL.createObjectURL(result.blob); + const previousUrl = previewUrlRef.current; + previewUrlRef.current = nextUrl; + if (previousUrl) { + URL.revokeObjectURL(previousUrl); + } + onStateChange({ + status: 'ready', + imageUrl: nextUrl, + aspectRatio: result.width / Math.max(1, result.height), + }); + }) + .catch((error) => { + console.error('[SnapshotPreviewRenderer] Failed to refresh preview.', error); + if (requestId !== previewRequestIdRef.current) { + return; + } + onStateChange({ + status: 'error', + imageUrl: previewUrlRef.current, + aspectRatio, + }); + }) + .finally(() => { + previewInFlightRef.current = false; + const queuedPreview = queuedPreviewRef.current; + if (!queuedPreview) { + return; + } + + queuedPreviewRef.current = null; + if (queuedPreview.requestId === previewRequestIdRef.current) { + executePreviewRequest( + queuedPreview.requestId, + queuedPreview.options, + queuedPreview.aspectRatio, + ); + } + }); + }, + [onStateChange, previewAction], + ); + + useEffect(() => { + if (!isOpen || !session) { + previewRequestIdRef.current += 1; + queuedPreviewRef.current = null; + if (previewTimerRef.current !== null) { + window.clearTimeout(previewTimerRef.current); + previewTimerRef.current = null; + } + if (previewUrlRef.current) { + URL.revokeObjectURL(previewUrlRef.current); + previewUrlRef.current = null; + } + onStateChange({ + status: 'idle', + imageUrl: null, + aspectRatio: session?.viewportAspectRatio ?? 16 / 9, + }); + return; + } + + if (!previewAction) { + return; + } + + const nextRequestId = ++previewRequestIdRef.current; + const previousImageUrl = previewUrlRef.current; + onStateChange({ + status: previousImageUrl ? 'refreshing' : 'loading', + imageUrl: previousImageUrl, + aspectRatio: session.viewportAspectRatio, + }); + + if (previewTimerRef.current !== null) { + window.clearTimeout(previewTimerRef.current); + } + + previewTimerRef.current = window.setTimeout(() => { + previewTimerRef.current = null; + executePreviewRequest(nextRequestId, options, session.viewportAspectRatio); + }, 300); + }, [executePreviewRequest, isOpen, onStateChange, options, previewAction, session]); + + if (!isOpen || !session) { + return null; + } + + return ( + + ); +} diff --git a/src/app/components/snapshot-preview/previewActionState.test.ts b/src/app/components/snapshot-preview/previewActionState.test.ts new file mode 100644 index 000000000..c04b9b162 --- /dev/null +++ b/src/app/components/snapshot-preview/previewActionState.test.ts @@ -0,0 +1,45 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import type { SnapshotPreviewAction } from '@/shared/components/3d'; + +import { toSnapshotPreviewActionState } from './previewActionState'; + +test('toSnapshotPreviewActionState preserves callback identity without invoking it', async () => { + let callCount = 0; + const action: SnapshotPreviewAction = async () => { + callCount += 1; + return { + blob: new Blob(['preview']), + width: 640, + height: 360, + options: { + longEdgePx: 1280, + imageFormat: 'png', + imageQuality: 96, + detailLevel: 'high', + environmentPreset: 'city', + shadowStyle: 'balanced', + groundStyle: 'shadow', + dofMode: 'off', + backgroundStyle: 'studio', + hideGrid: true, + }, + }; + }; + + const updater = toSnapshotPreviewActionState(action); + const storedAction = updater(null); + + assert.equal(callCount, 0, 'wrapping the callback should not invoke it eagerly'); + assert.equal(storedAction, action, 'the updater should preserve the original callback identity'); +}); + +test('toSnapshotPreviewActionState can clear the stored callback', () => { + const updater = toSnapshotPreviewActionState(null); + + assert.equal( + updater(() => Promise.reject(new Error('unused'))), + null, + ); +}); diff --git a/src/app/components/snapshot-preview/previewActionState.ts b/src/app/components/snapshot-preview/previewActionState.ts new file mode 100644 index 000000000..3597fd949 --- /dev/null +++ b/src/app/components/snapshot-preview/previewActionState.ts @@ -0,0 +1,5 @@ +import type { SnapshotPreviewAction } from '@/shared/components/3d'; + +export function toSnapshotPreviewActionState(nextAction: SnapshotPreviewAction | null) { + return (_previousAction: SnapshotPreviewAction | null) => nextAction; +} diff --git a/src/app/components/snapshot-preview/resolveSnapshotCaptureAction.test.ts b/src/app/components/snapshot-preview/resolveSnapshotCaptureAction.test.ts new file mode 100644 index 000000000..c2602a308 --- /dev/null +++ b/src/app/components/snapshot-preview/resolveSnapshotCaptureAction.test.ts @@ -0,0 +1,44 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import type { SnapshotCaptureAction } from '@/shared/components/3d'; + +import { resolveSnapshotCaptureAction } from './resolveSnapshotCaptureAction'; + +test('resolveSnapshotCaptureAction prefers the frozen preview capture path when requested', () => { + const liveCaptureAction: SnapshotCaptureAction = async () => {}; + const frozenPreviewCaptureAction: SnapshotCaptureAction = async () => {}; + + const resolvedAction = resolveSnapshotCaptureAction({ + liveCaptureAction, + frozenPreviewCaptureAction, + preferFrozenPreviewCapture: true, + }); + + assert.equal(resolvedAction, frozenPreviewCaptureAction); +}); + +test('resolveSnapshotCaptureAction keeps using the live viewer capture path when no frozen preview export is needed', () => { + const liveCaptureAction: SnapshotCaptureAction = async () => {}; + const frozenPreviewCaptureAction: SnapshotCaptureAction = async () => {}; + + const resolvedAction = resolveSnapshotCaptureAction({ + liveCaptureAction, + frozenPreviewCaptureAction, + preferFrozenPreviewCapture: false, + }); + + assert.equal(resolvedAction, liveCaptureAction); +}); + +test('resolveSnapshotCaptureAction does not fall back to the live viewer while a frozen preview export is pending', () => { + const liveCaptureAction: SnapshotCaptureAction = async () => {}; + + const resolvedAction = resolveSnapshotCaptureAction({ + liveCaptureAction, + frozenPreviewCaptureAction: null, + preferFrozenPreviewCapture: true, + }); + + assert.equal(resolvedAction, null); +}); diff --git a/src/app/components/snapshot-preview/resolveSnapshotCaptureAction.ts b/src/app/components/snapshot-preview/resolveSnapshotCaptureAction.ts new file mode 100644 index 000000000..9f01e800e --- /dev/null +++ b/src/app/components/snapshot-preview/resolveSnapshotCaptureAction.ts @@ -0,0 +1,15 @@ +import type { SnapshotCaptureAction } from '@/shared/components/3d'; + +interface ResolveSnapshotCaptureActionOptions { + liveCaptureAction: SnapshotCaptureAction | null; + frozenPreviewCaptureAction: SnapshotCaptureAction | null; + preferFrozenPreviewCapture: boolean; +} + +export function resolveSnapshotCaptureAction({ + liveCaptureAction, + frozenPreviewCaptureAction, + preferFrozenPreviewCapture, +}: ResolveSnapshotCaptureActionOptions): SnapshotCaptureAction | null { + return preferFrozenPreviewCapture ? frozenPreviewCaptureAction : liveCaptureAction; +} diff --git a/src/app/components/snapshot-preview/types.ts b/src/app/components/snapshot-preview/types.ts new file mode 100644 index 000000000..bef86ab0a --- /dev/null +++ b/src/app/components/snapshot-preview/types.ts @@ -0,0 +1,32 @@ +import type { ViewerJointMotionStateValue, ViewerRobotSourceFormat } from '@/features/editor'; +import type { WorkspaceCameraSnapshot } from '@/shared/components/3d'; +import type { RobotFile, RobotState } from '@/types'; +import type { Theme } from '@/types'; + +export type SnapshotDialogPreviewStatus = 'idle' | 'loading' | 'ready' | 'refreshing' | 'error'; + +export interface SnapshotDialogPreviewState { + status: SnapshotDialogPreviewStatus; + imageUrl: string | null; + aspectRatio: number; +} + +export interface SnapshotPreviewSession { + theme: Theme; + cameraSnapshot: WorkspaceCameraSnapshot | null; + viewportAspectRatio: number; + robotName: string; + robot: RobotState; + assets: Record; + availableFiles: RobotFile[]; + urdfContent: string; + viewerSourceFormat?: ViewerRobotSourceFormat; + sourceFilePath?: string; + sourceFile?: RobotFile | null; + jointAngleState?: Record; + jointMotionState?: Record; + showVisual: boolean; + isMeshPreview: boolean; + viewerReloadKey: number; + groundPlaneOffset: number; +} diff --git a/src/features/ai-assistant/components/AIInspectionModal.test.tsx b/src/features/ai-assistant/components/AIInspectionModal.test.tsx index 6339f6afa..1a04d9c45 100644 --- a/src/features/ai-assistant/components/AIInspectionModal.test.tsx +++ b/src/features/ai-assistant/components/AIInspectionModal.test.tsx @@ -10,6 +10,7 @@ import { __setPdfCanvasFactoryForTests, __setPdfGenerationDepsLoaderForTests, } from '@/features/file-io/utils/generatePdfFromHtml'; +import { INSPECTION_CRITERIA } from '../utils/inspectionCriteria'; import { GeometryType, JointType, type RobotState } from '@/types'; function installDom() { @@ -464,3 +465,1132 @@ test('saving the report from regenerate confirmation returns to the inspection r dom.window.close(); } }); + +test('confirming regenerate returns to setup and preserves the prior mode and selected checks', async () => { + const dom = installDom(); + const container = dom.window.document.getElementById('root'); + assert.ok(container, 'root container should exist'); + + dom.window.localStorage.setItem('urdf-studio.ai-inspection.setup-mode', 'normal'); + + const { AIInspectionModal } = await import('./AIInspectionModal.tsx'); + const root = createRoot(container); + const t = translations.zh; + const previousApiKey = process.env.API_KEY; + const totalItemCount = INSPECTION_CRITERIA.reduce( + (sum, category) => sum + category.items.length, + 0, + ); + const firstCategory = INSPECTION_CRITERIA[0]; + const firstItem = firstCategory?.items[0]; + assert.ok(firstCategory, 'expected inspection criteria to include at least one category'); + assert.ok(firstItem, 'expected the first category to include at least one item'); + + const getButtonByText = (label: string) => + Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent?.trim() === label, + ) ?? null; + + try { + delete process.env.API_KEY; + + await act(async () => { + root.render( + {}} + robot={createRobotFixture()} + lang="zh" + onSelectItem={() => {}} + onOpenConversationWithReport={() => {}} + />, + ); + }); + + const firstItemButton = getButtonByText(firstItem!.nameZh); + assert.ok(firstItemButton, 'expected the normal mode item button to render'); + + await act(async () => { + firstItemButton!.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })); + }); + + const runButton = getButtonByText(t.runInspection); + assert.ok(runButton, 'expected the run inspection button to render'); + + await act(async () => { + runButton!.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })); + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); + }); + + const regenerateButton = getButtonByText(t.retryLastResponse); + assert.ok(regenerateButton, 'expected the regenerate button to render in the report footer'); + + await act(async () => { + regenerateButton!.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })); + }); + + const confirmDialog = dom.window.document.querySelector('[role="dialog"][aria-modal="true"]'); + assert.ok(confirmDialog, 'expected regenerate confirmation dialog to open'); + + const confirmRegenerateButton = Array.from(confirmDialog.querySelectorAll('button')).find( + (button) => button.textContent?.trim() === t.retryLastResponse, + ); + assert.ok( + confirmRegenerateButton, + 'expected confirmation dialog to render the regenerate action', + ); + + await act(async () => { + confirmRegenerateButton!.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })); + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); + }); + + assert.equal( + dom.window.document.querySelector('[role="dialog"][aria-modal="true"]'), + null, + 'expected the confirmation dialog to close after confirming regenerate', + ); + assert.equal( + getButtonByText(t.discussReportWithAI), + null, + 'expected the report view to close after confirming regenerate', + ); + assert.equal( + container.textContent?.includes(t.inspectionConfigureChecks), + true, + 'expected confirming regenerate to return to the setup view', + ); + assert.equal( + container.textContent?.includes(t.inspectionScoringReference), + false, + 'expected the previously selected normal mode to remain active after returning to setup', + ); + + const summaryChip = container.querySelector('[data-inspection-normal-summary]'); + assert.ok(summaryChip, 'expected the setup summary chip to render after confirming regenerate'); + assert.equal( + summaryChip.textContent?.includes( + t.inspectionSelectedChecksSummary + .replace('{selected}', String(totalItemCount - 1)) + .replace('{total}', String(totalItemCount)), + ), + true, + 'expected the prior item selection to remain intact after confirming regenerate', + ); + assert.ok( + getButtonByText(t.runInspection), + 'expected the setup run button to render again after confirming regenerate', + ); + } finally { + if (previousApiKey === undefined) { + delete process.env.API_KEY; + } else { + process.env.API_KEY = previousApiKey; + } + await act(async () => { + root.unmount(); + }); + dom.window.close(); + } +}); + +test('inspection setup restores the saved normal mode and keeps selection in sync with advanced mode', async () => { + const dom = installDom(); + const container = dom.window.document.getElementById('root'); + assert.ok(container, 'root container should exist'); + + dom.window.localStorage.setItem('urdf-studio.ai-inspection.setup-mode', 'normal'); + + const { AIInspectionModal } = await import('./AIInspectionModal.tsx'); + const root = createRoot(container); + const t = translations.zh; + const totalItemCount = INSPECTION_CRITERIA.reduce( + (sum, category) => sum + category.items.length, + 0, + ); + const firstCategory = INSPECTION_CRITERIA[0]; + const firstItem = firstCategory?.items[0]; + assert.ok(firstCategory, 'expected inspection criteria to include at least one category'); + assert.ok(firstItem, 'expected the first category to include at least one item'); + + const getButtonByText = (label: string) => + Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent?.trim() === label, + ) ?? null; + + try { + await act(async () => { + root.render( + {}} + robot={createRobotFixture()} + lang="zh" + onSelectItem={() => {}} + onOpenConversationWithReport={() => {}} + />, + ); + }); + + assert.equal( + container.textContent?.includes(t.inspectionConfigureChecks), + true, + 'expected the saved normal mode to render the simplified setup heading', + ); + assert.equal( + container.textContent?.includes(t.inspectionScoringReference), + false, + 'expected the normal mode to hide advanced scoring references', + ); + assert.equal( + container.textContent?.includes('切换到专业模式'), + true, + 'expected the normal mode setup description to reference professional mode', + ); + assert.equal( + container.textContent?.includes('切换到高级模式'), + false, + 'expected the outdated advanced-mode wording to be removed from the normal mode description', + ); + + const firstItemButton = getButtonByText(firstItem!.nameZh); + assert.ok(firstItemButton, 'expected the normal mode item button to render'); + + await act(async () => { + firstItemButton!.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })); + }); + + const advancedModeButton = getButtonByText(t.inspectionAdvancedMode); + assert.ok(advancedModeButton, 'expected the advanced mode toggle to render'); + + await act(async () => { + advancedModeButton!.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })); + }); + + assert.equal( + container.textContent?.includes(t.inspectionScoringReference), + true, + 'expected the advanced mode to restore scoring references', + ); + assert.equal( + container.textContent?.includes( + t.inspectionSelectedChecksSummary + .replace('{selected}', String(totalItemCount - 1)) + .replace('{total}', String(totalItemCount)), + ), + true, + 'expected advanced mode to reflect the selection changed in normal mode', + ); + } finally { + await act(async () => { + root.unmount(); + }); + dom.window.close(); + } +}); + +test('professional mode status badge toggles the inspection item selection', async () => { + const dom = installDom(); + const container = dom.window.document.getElementById('root'); + assert.ok(container, 'root container should exist'); + + const { AIInspectionModal } = await import('./AIInspectionModal.tsx'); + const root = createRoot(container); + const t = translations.zh; + const totalItemCount = INSPECTION_CRITERIA.reduce( + (sum, category) => sum + category.items.length, + 0, + ); + const firstCategory = INSPECTION_CRITERIA[0]; + const firstItem = firstCategory?.items[0]; + assert.ok(firstCategory, 'expected inspection criteria to include at least one category'); + assert.ok(firstItem, 'expected the first category to include at least one item'); + + const getButtonByText = (label: string) => + Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent?.trim() === label, + ) ?? null; + + try { + await act(async () => { + root.render( + {}} + robot={createRobotFixture()} + lang="zh" + onSelectItem={() => {}} + onOpenConversationWithReport={() => {}} + />, + ); + }); + + const professionalModeButton = getButtonByText(t.inspectionAdvancedMode); + assert.ok(professionalModeButton, 'expected the professional mode toggle to render'); + + await act(async () => { + professionalModeButton!.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })); + }); + + const badge = container.querySelector( + `[data-inspection-setup-item-badge="${firstCategory!.id}:${firstItem!.id}"]`, + ); + assert.ok(badge, 'expected the focused item badge button to render'); + assert.equal(badge.textContent?.trim(), t.inspectionIncluded); + assert.equal(badge.getAttribute('aria-pressed'), 'true'); + + await act(async () => { + badge!.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })); + }); + + assert.equal(badge!.textContent?.trim(), t.inspectionSkipped); + assert.equal(badge!.getAttribute('aria-pressed'), 'false'); + + const summaryText = t.inspectionSelectedChecksSummary + .replace('{selected}', String(totalItemCount - 1)) + .replace('{total}', String(totalItemCount)); + assert.equal( + container.textContent?.includes(summaryText), + true, + 'expected the professional-mode summary to reflect the deselected item', + ); + } finally { + await act(async () => { + root.unmount(); + }); + dom.window.close(); + } +}); + +test('inspection setup normal mode shows the inline selection summary and page-level bulk actions', async () => { + const dom = installDom(); + const container = dom.window.document.getElementById('root'); + assert.ok(container, 'root container should exist'); + + dom.window.localStorage.setItem('urdf-studio.ai-inspection.setup-mode', 'normal'); + + const { AIInspectionModal } = await import('./AIInspectionModal.tsx'); + const root = createRoot(container); + const t = translations.zh; + const totalItemCount = INSPECTION_CRITERIA.reduce( + (sum, category) => sum + category.items.length, + 0, + ); + + const getButtonByText = (label: string) => + Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent?.trim() === label, + ) ?? null; + + try { + await act(async () => { + root.render( + {}} + robot={createRobotFixture()} + lang="zh" + onSelectItem={() => {}} + onOpenConversationWithReport={() => {}} + />, + ); + }); + + const summaryChip = container.querySelector('[data-inspection-normal-summary]'); + assert.ok(summaryChip, 'expected the normal mode header to render an inline selection summary'); + assert.equal( + summaryChip.textContent?.includes( + t.inspectionSelectedChecksSummary + .replace('{selected}', String(totalItemCount)) + .replace('{total}', String(totalItemCount)), + ), + true, + 'expected the inline summary to reflect the initial all-selected state', + ); + + assert.ok(getButtonByText('全选全部'), 'expected a page-level select-all action to render'); + assert.ok(getButtonByText('清空全部'), 'expected a page-level clear-all action to render'); + } finally { + await act(async () => { + root.unmount(); + }); + dom.window.close(); + } +}); + +test('inspection setup normal mode bulk actions keep selection counts and footer state in sync', async () => { + const dom = installDom(); + const container = dom.window.document.getElementById('root'); + assert.ok(container, 'root container should exist'); + + dom.window.localStorage.setItem('urdf-studio.ai-inspection.setup-mode', 'normal'); + + const { AIInspectionModal } = await import('./AIInspectionModal.tsx'); + const root = createRoot(container); + const t = translations.zh; + const totalItemCount = INSPECTION_CRITERIA.reduce( + (sum, category) => sum + category.items.length, + 0, + ); + + const getButtonByText = (label: string) => + Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent?.trim() === label, + ) ?? null; + + try { + await act(async () => { + root.render( + {}} + robot={createRobotFixture()} + lang="zh" + onSelectItem={() => {}} + onOpenConversationWithReport={() => {}} + />, + ); + }); + + const summaryChip = () => + container.querySelector('[data-inspection-normal-summary]'); + const getRunButton = () => + container.querySelector('[data-inspection-run-button]'); + assert.ok(getRunButton(), 'expected the normal mode run button to render'); + assert.equal(getRunButton()?.disabled, false, 'expected run inspection to start enabled'); + + const clearAllButton = getButtonByText('清空全部'); + assert.ok(clearAllButton, 'expected the normal mode clear-all action to render'); + + await act(async () => { + clearAllButton!.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })); + }); + + assert.equal( + summaryChip()?.textContent?.includes( + t.inspectionSelectedChecksSummary + .replace('{selected}', '0') + .replace('{total}', String(totalItemCount)), + ), + true, + 'expected clear-all to reset the inline summary count', + ); + assert.equal( + getRunButton()?.disabled, + true, + 'expected clear-all to disable running the inspection', + ); + + const selectAllButton = getButtonByText('全选全部'); + assert.ok(selectAllButton, 'expected the normal mode select-all action to render'); + + await act(async () => { + selectAllButton!.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })); + }); + + assert.equal( + summaryChip()?.textContent?.includes( + t.inspectionSelectedChecksSummary + .replace('{selected}', String(totalItemCount)) + .replace('{total}', String(totalItemCount)), + ), + true, + 'expected select-all to restore the inline summary count', + ); + assert.equal( + getRunButton()?.disabled, + false, + 'expected select-all to re-enable running the inspection', + ); + } finally { + await act(async () => { + root.unmount(); + }); + dom.window.close(); + } +}); + +test('inspection setup normal mode uses a compact visual scale aligned with advanced mode', async () => { + const dom = installDom(); + const container = dom.window.document.getElementById('root'); + assert.ok(container, 'root container should exist'); + + dom.window.localStorage.setItem('urdf-studio.ai-inspection.setup-mode', 'normal'); + + const { AIInspectionModal } = await import('./AIInspectionModal.tsx'); + const root = createRoot(container); + + try { + await act(async () => { + root.render( + {}} + robot={createRobotFixture()} + lang="zh" + onSelectItem={() => {}} + onOpenConversationWithReport={() => {}} + />, + ); + }); + + const title = container.querySelector('[data-inspection-normal-title]'); + assert.ok(title, 'expected the normal mode title to render a test hook'); + assert.equal( + title.className.includes('text-lg'), + true, + 'expected the normal mode title to use a compact heading scale', + ); + + const summaryChip = container.querySelector('[data-inspection-normal-summary]'); + assert.ok(summaryChip, 'expected the normal mode summary chip to render'); + assert.equal( + summaryChip.className.includes('text-[11px]'), + true, + 'expected the normal mode summary chip to use compact body sizing', + ); + + const actionButtons = Array.from( + container.querySelectorAll('[data-inspection-normal-action]'), + ); + assert.equal(actionButtons.length, 2, 'expected both normal mode bulk actions to render'); + assert.equal( + actionButtons.every((button) => button.className.includes('h-8')), + true, + 'expected the normal mode bulk actions to match the denser advanced-mode button height', + ); + + const firstCategoryCard = container.querySelector( + '[data-inspection-normal-category]', + ); + assert.ok(firstCategoryCard, 'expected a normal mode category card to render'); + assert.equal( + firstCategoryCard.className.includes('rounded-xl'), + true, + 'expected the normal mode category card to use the tighter card radius', + ); + + const categoryIcon = firstCategoryCard.querySelector( + '[data-inspection-normal-category-icon]', + ); + assert.ok(categoryIcon, 'expected the category card icon wrapper to render'); + assert.equal( + categoryIcon.className.includes('h-9 w-9'), + true, + 'expected the category icon wrapper to use the compact category scale', + ); + + const firstItemRow = firstCategoryCard.querySelector( + '[data-inspection-normal-item]', + ); + assert.ok(firstItemRow, 'expected a normal mode item row to render'); + assert.equal( + firstItemRow.className.includes('rounded-lg'), + true, + 'expected the normal mode item rows to use a tighter item shape', + ); + + await act(async () => { + firstItemRow.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })); + }); + + assert.equal( + firstItemRow.className.includes('border-border-black'), + true, + 'expected unchecked normal mode item rows to keep a visible border', + ); + assert.equal( + firstItemRow.className.includes('hover:border-system-blue/30'), + true, + 'expected unchecked normal mode item rows to highlight the border on hover', + ); + } finally { + await act(async () => { + root.unmount(); + }); + dom.window.close(); + } +}); + +test('inspection setup normal mode visually differentiates select-all and clear-all actions', async () => { + const dom = installDom(); + const container = dom.window.document.getElementById('root'); + assert.ok(container, 'root container should exist'); + + dom.window.localStorage.setItem('urdf-studio.ai-inspection.setup-mode', 'normal'); + + const { AIInspectionModal } = await import('./AIInspectionModal.tsx'); + const root = createRoot(container); + + try { + await act(async () => { + root.render( + {}} + robot={createRobotFixture()} + lang="zh" + onSelectItem={() => {}} + onOpenConversationWithReport={() => {}} + />, + ); + }); + + const selectAllButton = container.querySelector( + '[data-inspection-normal-action="select-all"]', + ); + const clearAllButton = container.querySelector( + '[data-inspection-normal-action="clear-all"]', + ); + + assert.ok(selectAllButton, 'expected the select-all action to render a dedicated test hook'); + assert.ok(clearAllButton, 'expected the clear-all action to render a dedicated test hook'); + assert.equal( + selectAllButton.className.includes('border-system-blue/25') && + selectAllButton.className.includes('bg-system-blue/10') && + selectAllButton.className.includes('text-system-blue'), + true, + 'expected select-all to use the emphasized positive action styling', + ); + assert.equal( + clearAllButton.className.includes('border-danger-border') && + clearAllButton.className.includes('bg-danger-soft') && + clearAllButton.className.includes('text-danger'), + true, + 'expected clear-all to use the reset action styling', + ); + } finally { + await act(async () => { + root.unmount(); + }); + dom.window.close(); + } +}); + +test('inspection setup normal mode footer uses a compact aligned count treatment', async () => { + const dom = installDom(); + const container = dom.window.document.getElementById('root'); + assert.ok(container, 'root container should exist'); + + dom.window.localStorage.setItem('urdf-studio.ai-inspection.setup-mode', 'normal'); + + const { AIInspectionModal } = await import('./AIInspectionModal.tsx'); + const root = createRoot(container); + + try { + await act(async () => { + root.render( + {}} + robot={createRobotFixture()} + lang="zh" + onSelectItem={() => {}} + onOpenConversationWithReport={() => {}} + />, + ); + }); + + const footerSummary = container.querySelector( + '[data-inspection-normal-footer-summary]', + ); + assert.ok(footerSummary, 'expected the normal mode footer to render a dedicated count summary'); + assert.equal( + footerSummary.className.includes('inline-flex items-center'), + true, + 'expected the footer summary to use an aligned inline-flex layout', + ); + + const primaryCount = container.querySelector( + '[data-inspection-normal-footer-primary-count]', + ); + const totalCount = container.querySelector( + '[data-inspection-normal-footer-total-count]', + ); + assert.ok(primaryCount, 'expected the footer summary to render the selected-count token'); + assert.ok(totalCount, 'expected the footer summary to render the total-count token'); + assert.equal( + primaryCount.className.includes('text-2xl'), + true, + 'expected the selected count to use the rebalanced primary size', + ); + assert.equal( + totalCount.className.includes('text-sm'), + true, + 'expected the total count to use the smaller supporting size', + ); + } finally { + await act(async () => { + root.unmount(); + }); + dom.window.close(); + } +}); + +test('inspection setup mode switcher uses the professional mode label', async () => { + const dom = installDom(); + const container = dom.window.document.getElementById('root'); + assert.ok(container, 'root container should exist'); + + const { AIInspectionModal } = await import('./AIInspectionModal.tsx'); + const root = createRoot(container); + const t = translations.zh; + + const getButtonByText = (label: string) => + Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent?.trim() === label, + ) ?? null; + + try { + await act(async () => { + root.render( + {}} + robot={createRobotFixture()} + lang="zh" + onSelectItem={() => {}} + onOpenConversationWithReport={() => {}} + />, + ); + }); + + assert.ok( + getButtonByText(t.inspectionAdvancedMode), + 'expected the setup mode switcher to render the renamed professional mode label', + ); + assert.equal( + getButtonByText('高级模式'), + null, + 'expected the old advanced mode label to stop rendering in the setup switcher', + ); + } finally { + await act(async () => { + root.unmount(); + }); + dom.window.close(); + } +}); + +test('inspection setup highlights the run inspection action from the window center with synced breathing', async () => { + const dom = installDom(); + const container = dom.window.document.getElementById('root'); + assert.ok(container, 'root container should exist'); + + dom.window.localStorage.setItem('urdf-studio.ai-inspection.setup-mode', 'normal'); + + const { AIInspectionModal } = await import('./AIInspectionModal.tsx'); + const root = createRoot(container); + const t = translations.zh; + + const getButtonByText = (label: string) => + Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent?.trim() === label, + ) ?? null; + + try { + await act(async () => { + root.render( + {}} + robot={createRobotFixture()} + lang="zh" + onSelectItem={() => {}} + onOpenConversationWithReport={() => {}} + />, + ); + }); + + const getRunButton = () => + container.querySelector('[data-inspection-run-button]'); + assert.ok(getRunButton(), 'expected the setup footer to expose the run inspection button hook'); + assert.equal( + getRunButton()?.className.includes('inspection-run-cta-pulse'), + true, + 'expected entering normal mode to pulse the run inspection button', + ); + + const pointerOverlay = container.querySelector( + '[data-inspection-run-pointer-overlay]', + ); + assert.ok(pointerOverlay, 'expected the pointer cue to render in a full-window overlay'); + assert.equal( + pointerOverlay.style.getPropertyValue('--inspection-run-pointer-origin-x'), + '50%', + 'expected the pointer cue to originate from the horizontal center of the modal window', + ); + assert.equal( + pointerOverlay.style.getPropertyValue('--inspection-run-pointer-origin-y'), + '50%', + 'expected the pointer cue to originate from the vertical center of the modal window', + ); + + const firstPointer = container.querySelector('[data-inspection-run-pointer]'); + assert.ok( + firstPointer, + 'expected entering setup mode to render a temporary pointer cue toward the run inspection button', + ); + assert.equal( + container.querySelector('[data-inspection-run-hint]'), + null, + 'expected the previous text hint capsule to be removed', + ); + assert.equal( + Boolean(firstPointer.querySelector('.inspection-run-pointer-cta')), + true, + 'expected the pointer cue to use the dedicated pointer animation styling', + ); + assert.equal( + getRunButton()?.className.includes('inspection-run-cta-breathe-sync'), + true, + 'expected the run inspection button to coordinate a breathing animation with the pointer cue', + ); + + await act(async () => { + await new Promise((resolve) => { + setTimeout(resolve, 2600); + }); + }); + + assert.equal( + container.querySelector('[data-inspection-run-pointer]'), + null, + 'expected the pointer cue to dismiss itself after the short guidance window', + ); + assert.equal( + getRunButton()?.className.includes('inspection-run-cta-breathe-sync'), + false, + 'expected the run inspection button to leave the synced breathing state after the cue ends', + ); + + const professionalModeButton = getButtonByText(t.inspectionAdvancedMode); + assert.ok( + professionalModeButton, + 'expected the setup mode switcher to render the professional mode', + ); + + await act(async () => { + professionalModeButton!.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })); + }); + + const secondPointer = container.querySelector('[data-inspection-run-pointer]'); + assert.ok( + secondPointer, + 'expected entering professional mode to trigger the pointer cue again', + ); + assert.equal( + getRunButton()?.className.includes('inspection-run-cta-pulse'), + true, + 'expected entering professional mode to re-apply the run inspection pulse', + ); + assert.equal( + getRunButton()?.className.includes('inspection-run-cta-breathe-sync'), + true, + 'expected entering professional mode to re-apply the synced breathing state', + ); + } finally { + await act(async () => { + root.unmount(); + }); + dom.window.close(); + } +}); + +test('inspection setup replays the run inspection cue when switching modes before the previous cue ends', async () => { + const dom = installDom(); + const container = dom.window.document.getElementById('root'); + assert.ok(container, 'root container should exist'); + + dom.window.localStorage.setItem('urdf-studio.ai-inspection.setup-mode', 'normal'); + + const { AIInspectionModal } = await import('./AIInspectionModal.tsx'); + const root = createRoot(container); + const t = translations.zh; + + const getSetupModeButton = (label: string) => + Array.from(container.querySelectorAll('[data-inspection-setup-mode-switcher] button')).find( + (button) => button.textContent?.trim() === label, + ) ?? null; + + try { + await act(async () => { + root.render( + {}} + robot={createRobotFixture()} + lang="zh" + onSelectItem={() => {}} + onOpenConversationWithReport={() => {}} + />, + ); + }); + + const initialPointer = container.querySelector('[data-inspection-run-pointer]'); + assert.ok(initialPointer, 'expected entering setup mode to render the initial pointer cue'); + + const professionalModeButton = getSetupModeButton(t.inspectionAdvancedMode); + assert.ok( + professionalModeButton, + 'expected the setup mode switcher to render the professional mode', + ); + + await act(async () => { + professionalModeButton!.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })); + await new Promise((resolve) => { + setTimeout(resolve, 1000); + }); + }); + + const normalModeButton = getSetupModeButton(t.inspectionNormalMode); + assert.ok(normalModeButton, 'expected the setup mode switcher to render the normal mode'); + + await act(async () => { + normalModeButton!.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })); + }); + + const replayedPointer = container.querySelector('[data-inspection-run-pointer]'); + assert.ok(replayedPointer, 'expected switching back to normal mode to keep the cue visible'); + assert.notEqual( + replayedPointer, + initialPointer, + 'expected the pointer cue to remount so the animation can replay before the previous cue ends', + ); + } finally { + await act(async () => { + root.unmount(); + }); + dom.window.close(); + } +}); + +test('inspection setup persists the last selected mode across remounts', async () => { + const dom = installDom(); + const container = dom.window.document.getElementById('root'); + assert.ok(container, 'root container should exist'); + + dom.window.localStorage.removeItem('urdf-studio.ai-inspection.setup-mode'); + + const { AIInspectionModal } = await import('./AIInspectionModal.tsx'); + const root = createRoot(container); + const t = translations.zh; + + const getButtonByText = (label: string) => + Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent?.trim() === label, + ) ?? null; + + try { + await act(async () => { + root.render( + {}} + robot={createRobotFixture()} + lang="zh" + onSelectItem={() => {}} + onOpenConversationWithReport={() => {}} + />, + ); + }); + + const advancedModeButton = getButtonByText(t.inspectionAdvancedMode); + assert.ok(advancedModeButton, 'expected the advanced mode toggle to render'); + + await act(async () => { + advancedModeButton!.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })); + }); + + assert.equal( + dom.window.localStorage.getItem('urdf-studio.ai-inspection.setup-mode'), + 'advanced', + 'expected mode changes to persist into local storage', + ); + + await act(async () => { + root.unmount(); + }); + + const reopenedRoot = createRoot(container); + + try { + await act(async () => { + reopenedRoot.render( + {}} + robot={createRobotFixture()} + lang="zh" + onSelectItem={() => {}} + onOpenConversationWithReport={() => {}} + />, + ); + }); + + assert.equal( + container.textContent?.includes(t.inspectionScoringReference), + true, + 'expected the remounted setup to restore the last selected advanced mode', + ); + assert.equal( + container.textContent?.includes(t.inspectionConfigureChecks), + false, + 'expected the remounted setup to skip the normal-mode layout when advanced was saved', + ); + } finally { + await act(async () => { + reopenedRoot.unmount(); + }); + } + } finally { + dom.window.close(); + } +}); + +test('inspection setup keeps the mode switcher visually centered in the header', async () => { + const dom = installDom(); + const container = dom.window.document.getElementById('root'); + assert.ok(container, 'root container should exist'); + + const { AIInspectionModal } = await import('./AIInspectionModal.tsx'); + const root = createRoot(container); + + try { + await act(async () => { + root.render( + {}} + robot={createRobotFixture()} + lang="zh" + onSelectItem={() => {}} + onOpenConversationWithReport={() => {}} + />, + ); + }); + + const modeSwitcher = container.querySelector( + '[data-inspection-setup-mode-switcher]', + ); + assert.ok( + modeSwitcher, + 'expected the setup header to render a dedicated mode switcher wrapper', + ); + assert.equal( + modeSwitcher.className.includes('absolute left-1/2 top-1/2'), + true, + 'expected the setup mode switcher to anchor from the visual center of the header', + ); + assert.equal( + modeSwitcher.className.includes('-translate-x-1/2 -translate-y-1/2'), + true, + 'expected the setup mode switcher to translate back from the anchor point for true centering', + ); + } finally { + await act(async () => { + root.unmount(); + }); + dom.window.close(); + } +}); + +test('inspection setup header uses the same maximize and restore icons as AI conversation', async () => { + const dom = installDom(); + const container = dom.window.document.getElementById('root'); + assert.ok(container, 'root container should exist'); + + const { AIInspectionModal } = await import('./AIInspectionModal.tsx'); + const root = createRoot(container); + const t = translations.zh; + + try { + await act(async () => { + root.render( + {}} + robot={createRobotFixture()} + lang="zh" + onSelectItem={() => {}} + onOpenConversationWithReport={() => {}} + />, + ); + }); + + const maximizeButton = container.querySelector( + `button[aria-label="${t.maximize}"]`, + ); + assert.ok(maximizeButton, 'expected the setup header maximize button to render'); + assert.ok( + maximizeButton.querySelector('svg.lucide-maximize-2'), + 'expected the setup header maximize button to use the shared maximize icon', + ); + + await act(async () => { + maximizeButton.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })); + }); + + const restoreButton = container.querySelector( + `button[aria-label="${t.restore}"]`, + ); + assert.ok(restoreButton, 'expected the setup header restore button to render after maximizing'); + assert.ok( + restoreButton.querySelector('svg.lucide-minimize-2'), + 'expected the setup header restore button to use the shared restore icon', + ); + } finally { + await act(async () => { + root.unmount(); + }); + dom.window.close(); + } +}); + +test('advanced setup summary chip uses content-based width instead of stretching across the footer', async () => { + const dom = installDom(); + const container = dom.window.document.getElementById('root'); + assert.ok(container, 'root container should exist'); + + dom.window.localStorage.setItem('urdf-studio.ai-inspection.setup-mode', 'advanced'); + + const { AIInspectionModal } = await import('./AIInspectionModal.tsx'); + const root = createRoot(container); + + try { + await act(async () => { + root.render( + {}} + robot={createRobotFixture()} + lang="zh" + onSelectItem={() => {}} + onOpenConversationWithReport={() => {}} + />, + ); + }); + + const summaryChip = container.querySelector('[data-inspection-setup-summary]'); + assert.ok(summaryChip, 'expected the advanced setup footer to render a summary chip wrapper'); + assert.equal( + summaryChip.className.includes('inline-flex'), + true, + 'expected the advanced setup summary chip to size to its content', + ); + assert.equal( + summaryChip.className.includes('w-fit'), + true, + 'expected the advanced setup summary chip to stop expanding toward the footer actions', + ); + } finally { + await act(async () => { + root.unmount(); + }); + dom.window.close(); + } +}); diff --git a/src/features/ai-assistant/components/AIInspectionModal.tsx b/src/features/ai-assistant/components/AIInspectionModal.tsx index e86e75bfc..75cc9511d 100644 --- a/src/features/ai-assistant/components/AIInspectionModal.tsx +++ b/src/features/ai-assistant/components/AIInspectionModal.tsx @@ -1,11 +1,13 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; -import { MessageCircle, ScanSearch } from 'lucide-react'; +import { useCallback, useEffect, useRef, useState, type CSSProperties } from 'react'; +import { createPortal } from 'react-dom'; +import { Bot, MessageCircle, ScanSearch } from 'lucide-react'; import type { InspectionReport, RobotState } from '@/types'; import type { Language } from '@/shared/i18n'; import { translations } from '@/shared/i18n'; import { DraggableWindow } from '@/shared/components'; import { Button } from '@/shared/components/ui/Button'; import { Dialog } from '@/shared/components/ui/Dialog'; +import { SegmentedControl } from '@/shared/components/ui/SegmentedControl'; import { useDraggableWindow } from '@/shared/hooks'; import { runRobotInspection } from '../services/aiService'; import { calculateOverallScore, INSPECTION_CRITERIA } from '../utils/inspectionCriteria'; @@ -23,6 +25,7 @@ import { InspectionReportView, } from './InspectionReport'; import { InspectionSidebar, type SelectedInspectionItems } from './InspectionSidebar'; +import { InspectionSetupNormalView } from './InspectionSetupNormalView'; import { InspectionSetupView } from './InspectionSetupView'; interface AIInspectionModalProps { @@ -50,6 +53,34 @@ interface ReportScrollTarget { anchorId: string; } +interface InspectionRunPointerLayout { + deltaX: number; + deltaY: number; + targetX: number; + targetY: number; +} + +type InspectionSetupMode = 'normal' | 'advanced'; + +const INSPECTION_SETUP_MODE_STORAGE_KEY = 'urdf-studio.ai-inspection.setup-mode'; +const TOTAL_INSPECTION_ITEM_COUNT = INSPECTION_CRITERIA.reduce( + (sum, category) => sum + category.items.length, + 0, +); + +function readStoredInspectionSetupMode(): InspectionSetupMode { + if (typeof window === 'undefined') { + return 'advanced'; + } + + try { + const storedMode = window.localStorage.getItem(INSPECTION_SETUP_MODE_STORAGE_KEY); + return storedMode === 'normal' || storedMode === 'advanced' ? storedMode : 'advanced'; + } catch { + return 'advanced'; + } +} + function createInitialSelectedItems(): SelectedInspectionItems { const initial: SelectedInspectionItems = {}; INSPECTION_CRITERIA.forEach((category) => { @@ -132,6 +163,18 @@ export function AIInspectionModal({ const [selectedItems, setSelectedItems] = useState(() => createInitialSelectedItems(), ); + const [inspectionSetupMode, setInspectionSetupMode] = useState(() => + readStoredInspectionSetupMode(), + ); + const [showRunInspectionPointer, setShowRunInspectionPointer] = useState(false); + const [runInspectionPointerReplayToken, setRunInspectionPointerReplayToken] = useState(0); + const [runInspectionPointerLayout, setRunInspectionPointerLayout] = + useState({ + deltaX: 0, + deltaY: 0, + targetX: 0, + targetY: 0, + }); const [focusedCategoryId, setFocusedCategoryId] = useState( INSPECTION_CRITERIA[0]?.id ?? '', ); @@ -144,6 +187,9 @@ export function AIInspectionModal({ const retestRequestIdRef = useRef(0); const reportScrollViewportRef = useRef(null); const inspectionTimerRef = useRef | null>(null); + const runInspectionPointerTimerRef = useRef | null>(null); + const lastRunInspectionPointerKeyRef = useRef(null); + const runInspectionButtonRef = useRef(null); let totalSelectedCount = 0; let selectedCategoryCount = 0; @@ -168,6 +214,25 @@ export function AIInspectionModal({ } }, []); + const clearRunInspectionPointerTimer = useCallback(() => { + if (runInspectionPointerTimerRef.current !== null) { + clearTimeout(runInspectionPointerTimerRef.current); + runInspectionPointerTimerRef.current = null; + } + }, []); + + useEffect(() => { + if (typeof window === 'undefined') { + return; + } + + try { + window.localStorage.setItem(INSPECTION_SETUP_MODE_STORAGE_KEY, inspectionSetupMode); + } catch { + // Ignore storage write failures and keep the in-memory mode. + } + }, [inspectionSetupMode]); + useEffect(() => { isMountedRef.current = true; @@ -176,12 +241,14 @@ export function AIInspectionModal({ inspectionRunIdRef.current += 1; retestRequestIdRef.current += 1; clearInspectionTimer(); + clearRunInspectionPointerTimer(); }; - }, [clearInspectionTimer]); + }, [clearInspectionTimer, clearRunInspectionPointerTimer]); const handleClose = useCallback(() => { setIsRegenerateConfirmOpen(false); setIsSavingReportBeforeRegenerate(false); + setShowRunInspectionPointer(false); onClose(); }, [onClose]); @@ -422,6 +489,35 @@ export function AIInspectionModal({ setIsRegenerateConfirmOpen(false); }; + const handleReturnToSetupFromRegenerate = useCallback(() => { + clearInspectionTimer(); + setIsRegenerateConfirmOpen(false); + setIsSavingReportBeforeRegenerate(false); + setInspectionProgress(null); + setInspectionRunContext(null); + setInspectionElapsedSeconds(0); + setInspectionReport(null); + setPendingReportScrollTarget(null); + setRetestingItem(null); + setIsInspecting(false); + }, [clearInspectionTimer]); + + const handleToggleSelectedItem = useCallback((categoryId: string, itemId: string) => { + setSelectedItems((prev) => { + const next = { ...prev }; + const currentItems = new Set(next[categoryId] ?? []); + + if (currentItems.has(itemId)) { + currentItems.delete(itemId); + } else { + currentItems.add(itemId); + } + + next[categoryId] = currentItems; + return next; + }); + }, []); + const handleAskAboutIssue = useCallback( (issue: InspectionReport['issues'][number]) => { if (!inspectionReport) { @@ -436,10 +532,117 @@ export function AIInspectionModal({ [inspectionReport, onOpenConversationWithReport, robot], ); + const isSetupView = !inspectionProgress && !inspectionReport; + const shouldShowRunInspectionPointer = + isSetupView && showRunInspectionPointer && totalSelectedCount > 0 && !isMinimized; + const runInspectionPointerKey = `${isOpen}:${isSetupView}:${inspectionSetupMode}:${isMinimized}`; + const inspectionSetupSummary = + `${t.inspectionRunSummary}${lang === 'zh' ? ':' : ': '}` + + `${t.inspectionSelectedChecks.replace('{count}', String(totalSelectedCount))} | ` + + `${t.inspectionSelectedCategories}: ${selectedCategoryCount} | ` + + `${t.inspectionWeightedCoverage}: ${selectedWeightPercentage}% | ` + + `${t.inspectionMaxPossibleScore}: ${maxPossibleScore}`; + + useEffect(() => { + if (!isOpen || !isSetupView) { + lastRunInspectionPointerKeyRef.current = null; + setShowRunInspectionPointer(false); + clearRunInspectionPointerTimer(); + return; + } + + if (isMinimized || totalSelectedCount === 0) { + setShowRunInspectionPointer(false); + clearRunInspectionPointerTimer(); + return; + } + + if (lastRunInspectionPointerKeyRef.current === runInspectionPointerKey) { + return; + } + + lastRunInspectionPointerKeyRef.current = runInspectionPointerKey; + setShowRunInspectionPointer(true); + setRunInspectionPointerReplayToken((current) => current + 1); + clearRunInspectionPointerTimer(); + + runInspectionPointerTimerRef.current = setTimeout(() => { + if (isMountedRef.current) { + setShowRunInspectionPointer(false); + } + }, 2400); + + return () => { + clearRunInspectionPointerTimer(); + }; + }, [ + clearRunInspectionPointerTimer, + isMinimized, + isOpen, + isSetupView, + inspectionSetupMode, + runInspectionPointerKey, + totalSelectedCount, + ]); + + useEffect(() => { + if (!shouldShowRunInspectionPointer) { + return; + } + + const frameId = window.requestAnimationFrame(() => { + const windowContainer = windowState.containerRef.current; + const runButton = runInspectionButtonRef.current; + const containerRect = windowContainer?.getBoundingClientRect(); + const buttonRect = runButton?.getBoundingClientRect(); + const containerWidth = containerRect?.width || size.width; + const containerHeight = containerRect?.height || size.height; + const originX = containerWidth / 2; + const originY = containerHeight / 2; + const fallbackTargetX = containerWidth - 116; + const fallbackTargetY = containerHeight - 54; + const targetX = + containerRect && buttonRect && buttonRect.width > 0 + ? buttonRect.left - containerRect.left + buttonRect.width * 0.5 + : fallbackTargetX; + const targetY = + containerRect && buttonRect && buttonRect.height > 0 + ? buttonRect.top - containerRect.top + buttonRect.height * 0.5 + : fallbackTargetY; + + setRunInspectionPointerLayout({ + deltaX: targetX - originX, + deltaY: targetY - originY, + targetX, + targetY, + }); + }); + + return () => { + window.cancelAnimationFrame(frameId); + }; + }, [ + shouldShowRunInspectionPointer, + inspectionSetupMode, + isOpen, + size.height, + size.width, + windowState.containerRef, + ]); + if (!isOpen) { return null; } + const runInspectionPointerOverlayStyle = { + '--inspection-run-pointer-origin-x': '50%', + '--inspection-run-pointer-origin-y': '50%', + '--inspection-run-pointer-dx': `${runInspectionPointerLayout.deltaX}px`, + '--inspection-run-pointer-dy': `${runInspectionPointerLayout.deltaY}px`, + '--inspection-run-pointer-target-x': `${runInspectionPointerLayout.targetX}px`, + '--inspection-run-pointer-target-y': `${runInspectionPointerLayout.targetY}px`, + } as CSSProperties; + return ( <>