Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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
112 changes: 100 additions & 12 deletions src/app/AppLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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,
Expand Down Expand Up @@ -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';
Expand All @@ -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;
Expand Down Expand Up @@ -165,6 +172,7 @@ export function AppLayout({
sidebarTab,
sourceCodeAutoApply,
setViewOption,
groundPlaneOffset,
} = useUIStore(
useShallow((state) => ({
appMode: state.appMode,
Expand All @@ -176,6 +184,7 @@ export function AppLayout({
sidebarTab: state.sidebarTab,
sourceCodeAutoApply: state.sourceCodeAutoApply,
setViewOption: state.setViewOption,
groundPlaneOffset: state.groundPlaneOffset,
})),
);

Expand Down Expand Up @@ -343,6 +352,7 @@ export function AppLayout({
const snapshotActionRef = useRef<
((options?: Partial<SnapshotCaptureOptions>) => Promise<void>) | null
>(null);
const viewerCanvasStateRef = useRef<RootState | null>(null);
const transformPendingRef = useRef(false);
const pendingUsdAssemblyFileRef = useRef<RobotFile | null>(null);
const proModeRoundtripSessionRef = useRef<ProModeRoundtripSession | null>(null);
Expand All @@ -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<SnapshotPreviewSession | null>(null);
const snapshotPreviewCaptureActionRef = useRef<SnapshotCaptureAction | null>(null);
const [isIkToolPanelOpen, setIsIkToolPanelOpen] = useState(false);
const [shouldRenderBridgeModal, setShouldRenderBridgeModal] = useState(false);
const [bridgePreview, setBridgePreview] = useState<BridgeJoint | null>(null);
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -866,23 +948,28 @@ 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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The snapshot dialog no longer closes automatically after a successful capture. This seems like a regression from the previous behavior. Was this intentional? If not, you should call handleCloseSnapshotDialog() after the capture is complete to restore the old behavior and improve user experience.

The handleCloseSnapshotDialog function is already in the dependency array of handleCaptureSnapshot, which suggests it was intended to be used here.

Suggested change
await captureAction(options);
await captureAction(options);
handleCloseSnapshotDialog();

} catch (error) {
console.error('Snapshot failed:', error);
showToast(t.snapshotFailed, 'info');
} finally {
setIsSnapshotCapturing(false);
}
},
[showToast, t],
[handleCloseSnapshotDialog, showToast, snapshotPreviewSession, t],
);

const {
Expand Down Expand Up @@ -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}
Expand All @@ -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}
Expand Down Expand Up @@ -1134,7 +1220,9 @@ export function AppLayout({
isOpen={isSnapshotDialogOpen}
isCapturing={isSnapshotCapturing}
lang={lang}
onClose={() => setIsSnapshotDialogOpen(false)}
previewSession={snapshotPreviewSession}
onPreviewCaptureActionChange={handleSnapshotPreviewCaptureActionChange}
onClose={handleCloseSnapshotDialog}
onCapture={handleCaptureSnapshot}
/>

Expand Down
Loading
Loading