diff --git a/e2e/playwright/fixtures/editorFixture.ts b/e2e/playwright/fixtures/editorFixture.ts index ecd768c8e..b8c5e2d82 100644 --- a/e2e/playwright/fixtures/editorFixture.ts +++ b/e2e/playwright/fixtures/editorFixture.ts @@ -171,4 +171,22 @@ export class EditorFixture { { text, placeCursor } ) } + async selectText(text: string) { + // First make sure the code pane is open + const wasPaneOpen = await this.checkIfPaneIsOpen() + if (!wasPaneOpen) { + await this.openPane() + } + + // Use Playwright's built-in text selection on the code content + // it seems to only select whole divs, which works out to align with syntax highlighting + // for code mirror, so you can probably select "sketch002 = startSketchOn('XZ')" + // but less so for exactly "sketch002 = startS" + await this.codeContent.getByText(text).first().selectText() + + // Reset pane state if needed + if (!wasPaneOpen) { + await this.closePane() + } + } } diff --git a/e2e/playwright/prompt-to-edit.spec.ts b/e2e/playwright/prompt-to-edit.spec.ts index 23d6de648..7950b2d17 100644 --- a/e2e/playwright/prompt-to-edit.spec.ts +++ b/e2e/playwright/prompt-to-edit.spec.ts @@ -36,7 +36,7 @@ extrude003 = extrude(sketch003, length = 20) ` test.describe('Prompt-to-edit tests', { tag: '@skipWin' }, () => { - test.fixme('Check the happy path, for basic changing color', () => { + test.describe('Check the happy path, for basic changing color', () => { const cases = [ { desc: 'User accepts change', @@ -70,7 +70,7 @@ test.describe('Prompt-to-edit tests', { tag: '@skipWin' }, () => { body1CapCoords.y ) const yellow: [number, number, number] = [179, 179, 131] - const green: [number, number, number] = [108, 152, 75] + const green: [number, number, number] = [128, 194, 88] const notGreen: [number, number, number] = [132, 132, 132] const body2NotGreen: [number, number, number] = [88, 88, 88] const submittingToast = page.getByText( @@ -109,7 +109,7 @@ test.describe('Prompt-to-edit tests', { tag: '@skipWin' }, () => { }) await test.step('verify initial change', async () => { - await scene.expectPixelColor(green, greenCheckCoords, 15) + await scene.expectPixelColor(green, greenCheckCoords, 20) await scene.expectPixelColor(body2NotGreen, body2WallCoords, 15) await editor.expectEditor.toContain('appearance(') }) @@ -142,7 +142,7 @@ test.describe('Prompt-to-edit tests', { tag: '@skipWin' }, () => { } }) - test(`bad edit prompt`, async ({ + test('bad edit prompt', async ({ context, homePage, cmdBar, @@ -195,4 +195,150 @@ test.describe('Prompt-to-edit tests', { tag: '@skipWin' }, () => { await expect(failToast).toBeVisible() }) }) + + test(`manual code selection rename`, async ({ + context, + homePage, + cmdBar, + editor, + page, + scene, + }) => { + const body1CapCoords = { x: 571, y: 351 } + + await context.addInitScript((file) => { + localStorage.setItem('persistCode', file) + }, file) + await homePage.goToModelingScene() + await scene.waitForExecutionDone() + + const submittingToast = page.getByText('Submitting to Text-to-CAD API...') + const successToast = page.getByText('Prompt to edit successful') + const acceptBtn = page.getByRole('button', { name: 'checkmark Accept' }) + + await test.step('wait for scene to load and select code in editor', async () => { + // Find and select the text "sketch002" in the editor + await editor.selectText('sketch002') + + // Verify the selection was made + await editor.expectState({ + highlightedCode: '', + activeLines: ["sketch002 = startSketchOn('XZ')"], + diagnostics: [], + }) + }) + + await test.step('fire off edit prompt', async () => { + await scene.expectPixelColor([134, 134, 134], body1CapCoords, 15) + await cmdBar.openCmdBar('promptToEdit') + await page + .getByTestId('cmd-bar-arg-value') + .fill('Please rename to mySketch') + await page.waitForTimeout(100) + await cmdBar.progressCmdBar() + await expect(submittingToast).toBeVisible() + await expect(submittingToast).not.toBeVisible({ + timeout: 2 * 60_000, + }) + await expect(successToast).toBeVisible() + }) + + await test.step('verify rename change and accept it', async () => { + await editor.expectEditor.toContain('mySketch = startSketchOn') + await editor.expectEditor.not.toContain('sketch002 = startSketchOn') + await editor.expectEditor.toContain( + 'extrude002 = extrude(mySketch, length = 50)' + ) + + await acceptBtn.click() + await expect(successToast).not.toBeVisible() + }) + }) + + test('multiple body selections', async ({ + context, + homePage, + cmdBar, + editor, + page, + scene, + }) => { + const body1CapCoords = { x: 571, y: 351 } + const body2WallCoords = { x: 620, y: 152 } + const [clickBody1Cap] = scene.makeMouseHelpers( + body1CapCoords.x, + body1CapCoords.y + ) + const [clickBody2Cap] = scene.makeMouseHelpers( + body2WallCoords.x, + body2WallCoords.y + ) + const grey: [number, number, number] = [132, 132, 132] + + await context.addInitScript((file) => { + localStorage.setItem('persistCode', file) + }, file) + await homePage.goToModelingScene() + await scene.waitForExecutionDone() + + const submittingToast = page.getByText('Submitting to Text-to-CAD API...') + const successToast = page.getByText('Prompt to edit successful') + const acceptBtn = page.getByRole('button', { name: 'checkmark Accept' }) + + await test.step('select multiple bodies and fire prompt', async () => { + // Initial color check + await scene.expectPixelColor(grey, body1CapCoords, 15) + + // Open command bar first (without selection) + await cmdBar.openCmdBar('promptToEdit') + + // Select first body + await page.waitForTimeout(100) + await clickBody1Cap() + + // Hold shift and select second body + await editor.expectState({ + highlightedCode: '', + activeLines: ['|>startProfileAt([-73.64,-42.89],%)'], + diagnostics: [], + }) + await page.keyboard.down('Shift') + await page.waitForTimeout(100) + await clickBody2Cap() + await editor.expectState({ + highlightedCode: + 'line(end=[121.13,56.63],tag=$seg02)extrude(profile001,length=200)', + activeLines: [ + '|>line(end=[121.13,56.63],tag=$seg02)', + '|>startProfileAt([-73.64,-42.89],%)', + ], + diagnostics: [], + }) + await page.keyboard.up('Shift') + await page.waitForTimeout(100) + await cmdBar.progressCmdBar() + + // Enter prompt and submit + await page + .getByTestId('cmd-bar-arg-value') + .fill('make these neon green please, use #39FF14') + await page.waitForTimeout(100) + await cmdBar.progressCmdBar() + + // Wait for API response + await expect(submittingToast).toBeVisible() + await expect(submittingToast).not.toBeVisible({ + timeout: 2 * 60_000, + }) + await expect(successToast).toBeVisible() + }) + + await test.step('verify code changed', async () => { + await editor.expectEditor.toContain('appearance(') + + // Accept changes + await acceptBtn.click() + await expect(successToast).not.toBeVisible() + }) + }) }) diff --git a/e2e/playwright/snapshot-tests.spec.ts-snapshots/code-color-goober-code-color-goober-1-Google-Chrome-linux.png b/e2e/playwright/snapshot-tests.spec.ts-snapshots/code-color-goober-code-color-goober-1-Google-Chrome-linux.png index 15105d98a..51f2107b1 100644 Binary files a/e2e/playwright/snapshot-tests.spec.ts-snapshots/code-color-goober-code-color-goober-1-Google-Chrome-linux.png and b/e2e/playwright/snapshot-tests.spec.ts-snapshots/code-color-goober-code-color-goober-1-Google-Chrome-linux.png differ diff --git a/src/components/CommandBar/CommandBar.tsx b/src/components/CommandBar/CommandBar.tsx index 8523f3e3f..93fd576d4 100644 --- a/src/components/CommandBar/CommandBar.tsx +++ b/src/components/CommandBar/CommandBar.tsx @@ -17,7 +17,9 @@ export const CommandBar = () => { const { context: { selectedCommand, currentArgument, commands }, } = commandBarState - const isSelectionArgument = currentArgument?.inputType === 'selection' + const isSelectionArgument = + currentArgument?.inputType === 'selection' || + currentArgument?.inputType === 'selectionMixed' const WrapperComponent = isSelectionArgument ? Popover : Dialog // Close the command bar when navigating diff --git a/src/components/CommandBar/CommandBarArgument.tsx b/src/components/CommandBar/CommandBarArgument.tsx index 792c45f62..2251ac629 100644 --- a/src/components/CommandBar/CommandBarArgument.tsx +++ b/src/components/CommandBar/CommandBarArgument.tsx @@ -1,6 +1,7 @@ import CommandArgOptionInput from './CommandArgOptionInput' import CommandBarBasicInput from './CommandBarBasicInput' import CommandBarSelectionInput from './CommandBarSelectionInput' +import CommandBarSelectionMixedInput from './CommandBarSelectionMixedInput' import { CommandArgument } from 'lib/commandTypes' import CommandBarHeader from './CommandBarHeader' import CommandBarKclInput from './CommandBarKclInput' @@ -84,6 +85,14 @@ function ArgumentInput({ onSubmit={onSubmit} /> ) + case 'selectionMixed': + return ( + + ) case 'kcl': return ( diff --git a/src/components/CommandBar/CommandBarHeader.tsx b/src/components/CommandBar/CommandBarHeader.tsx index 0fbf018df..1fe4ebb8a 100644 --- a/src/components/CommandBar/CommandBarHeader.tsx +++ b/src/components/CommandBar/CommandBarHeader.tsx @@ -124,7 +124,8 @@ function CommandBarHeader({ children }: React.PropsWithChildren<{}>) { {argValue ? ( - arg.inputType === 'selection' ? ( + arg.inputType === 'selection' || + arg.inputType === 'selectionMixed' ? ( getSelectionTypeDisplayText(argValue as Selections) ) : arg.inputType === 'kcl' ? ( roundOff( diff --git a/src/components/CommandBar/CommandBarSelectionMixedInput.tsx b/src/components/CommandBar/CommandBarSelectionMixedInput.tsx new file mode 100644 index 000000000..5c46a0d48 --- /dev/null +++ b/src/components/CommandBar/CommandBarSelectionMixedInput.tsx @@ -0,0 +1,135 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { CommandArgument } from 'lib/commandTypes' +import { + Selections, + canSubmitSelectionArg, + getSelectionCountByType, + getSelectionTypeDisplayText, +} from 'lib/selections' +import { useSelector } from '@xstate/react' +import { commandBarActor, useCommandBarState } from 'machines/commandBarMachine' + +const selectionSelector = (snapshot: any) => snapshot?.context.selectionRanges + +export default function CommandBarSelectionMixedInput({ + arg, + stepBack, + onSubmit, +}: { + arg: CommandArgument & { inputType: 'selectionMixed'; name: string } + stepBack: () => void + onSubmit: (data: unknown) => void +}) { + const inputRef = useRef(null) + const commandBarState = useCommandBarState() + const [hasSubmitted, setHasSubmitted] = useState(false) + const [hasAutoSkipped, setHasAutoSkipped] = useState(false) + const selection: Selections = useSelector(arg.machineActor, selectionSelector) + + const selectionsByType = useMemo(() => { + return getSelectionCountByType(selection) + }, [selection]) + + const canSubmitSelection = useMemo(() => { + if (!selection) return false + const isNonZeroRange = selection.graphSelections.some((sel) => { + const range = sel.codeRef.range + return range[1] - range[0] !== 0 // Non-zero range is always valid + }) + if (isNonZeroRange) return true + return canSubmitSelectionArg(selectionsByType, arg) + }, [selectionsByType, selection]) + + useEffect(() => { + inputRef.current?.focus() + }, [selection, inputRef]) + + // Only auto-skip on initial mount if we have a valid selection + // different from the component CommandBarSelectionInput in the the dependency array + // is empty + useEffect(() => { + if (!hasAutoSkipped && canSubmitSelection && arg.skip) { + const argValue = commandBarState.context.argumentsToSubmit[arg.name] + if (argValue === undefined) { + handleSubmit() + setHasAutoSkipped(true) + } + } + }, []) + + function handleChange() { + inputRef.current?.focus() + } + + function handleSubmit(e?: React.FormEvent) { + e?.preventDefault() + + if (!canSubmitSelection) { + setHasSubmitted(true) + return + } + + onSubmit(selection) + } + + const isMixedSelection = arg.inputType === 'selectionMixed' + const allowNoSelection = isMixedSelection && arg.allowNoSelection + const showSceneSelection = + isMixedSelection && arg.selectionSource?.allowSceneSelection + + return ( +
+ +
+ ) +} diff --git a/src/lib/commandBarConfigs/modelingCommandConfig.ts b/src/lib/commandBarConfigs/modelingCommandConfig.ts index 5b16b1d05..a9f0d54e3 100644 --- a/src/lib/commandBarConfigs/modelingCommandConfig.ts +++ b/src/lib/commandBarConfigs/modelingCommandConfig.ts @@ -666,7 +666,7 @@ export const modelingMachineCommandConfig: StateMachineCommandSetConfig< icon: 'chat', args: { selection: { - inputType: 'selection', + inputType: 'selectionMixed', selectionTypes: [ 'solid2d', 'segment', @@ -678,6 +678,10 @@ export const modelingMachineCommandConfig: StateMachineCommandSetConfig< ], multiple: true, required: true, + selectionSource: { + allowSceneSelection: true, + allowCodeSelection: true, + }, skip: true, }, prompt: { diff --git a/src/lib/commandTypes.ts b/src/lib/commandTypes.ts index ce99e42d2..0bb8ee579 100644 --- a/src/lib/commandTypes.ts +++ b/src/lib/commandTypes.ts @@ -16,6 +16,7 @@ const INPUT_TYPES = [ 'text', 'kcl', 'selection', + 'selectionMixed', 'boolean', ] as const export interface KclExpression { @@ -156,6 +157,23 @@ export type CommandArgumentConfig< context: CommandBarContext }) => Promise } + | { + inputType: 'selectionMixed' + selectionTypes: Artifact['type'][] + multiple: boolean + allowNoSelection?: boolean + validation?: ({ + data, + context, + }: { + data: any + context: CommandBarContext + }) => Promise + selectionSource?: { + allowSceneSelection?: boolean + allowCodeSelection?: boolean + } + } | { inputType: 'kcl' createVariableByDefault?: boolean @@ -252,6 +270,23 @@ export type CommandArgument< context: CommandBarContext }) => Promise } + | { + inputType: 'selectionMixed' + selectionTypes: Artifact['type'][] + multiple: boolean + allowNoSelection?: boolean + validation?: ({ + data, + context, + }: { + data: any + context: CommandBarContext + }) => Promise + selectionSource?: { + allowSceneSelection?: boolean + allowCodeSelection?: boolean + } + } | { inputType: 'kcl' createVariableByDefault?: boolean diff --git a/src/lib/createMachineCommand.ts b/src/lib/createMachineCommand.ts index 126f51164..c89cdd448 100644 --- a/src/lib/createMachineCommand.ts +++ b/src/lib/createMachineCommand.ts @@ -187,6 +187,16 @@ export function buildCommandArgument< selectionTypes: arg.selectionTypes, validation: arg.validation, } satisfies CommandArgument & { inputType: 'selection' } + } else if (arg.inputType === 'selectionMixed') { + return { + inputType: arg.inputType, + ...baseCommandArgument, + multiple: arg.multiple, + selectionTypes: arg.selectionTypes, + validation: arg.validation, + allowNoSelection: arg.allowNoSelection, + selectionSource: arg.selectionSource, + } satisfies CommandArgument & { inputType: 'selectionMixed' } } else if (arg.inputType === 'kcl') { return { inputType: arg.inputType, diff --git a/src/lib/promptToEdit.ts b/src/lib/promptToEdit.ts index bb3eb025d..1578a428e 100644 --- a/src/lib/promptToEdit.ts +++ b/src/lib/promptToEdit.ts @@ -43,15 +43,33 @@ export async function submitPromptToEditToQueue({ projectName, }: { prompt: string - selections: Selections + selections: Selections | null code: string projectName: string token?: string artifactGraph: ArtifactGraph }): Promise { + // If no selection, use whole file + if (selections === null) { + const body: Models['TextToCadIterationBody_type'] = { + original_source_code: code, + prompt, + source_ranges: [], // Empty ranges indicates whole file + project_name: + projectName !== '' && projectName !== 'browser' + ? projectName + : undefined, + kcl_version: kclManager.kclVersion, + } + return submitToApi(body, token) + } + + // Handle manual code selections and artifact selections differently const ranges: Models['TextToCadIterationBody_type']['source_ranges'] = selections.graphSelections.flatMap((selection) => { const artifact = selection.artifact + + // For artifact selections, add context const prompts: Models['TextToCadIterationBody_type']['source_ranges'] = [] if (artifact?.type === 'cap') { @@ -153,8 +171,17 @@ See later source ranges for more context. about the sweep`, } } } + if (!artifact) { + // manually selected code is more likely to not have an artifact + // an example might be highlighting the variable name only in a variable declaration + prompts.push({ + prompt: '', + range: convertAppRangeToApiRange(selection.codeRef.range, code), + }) + } return prompts }) + const body: Models['TextToCadIterationBody_type'] = { original_source_code: code, prompt, @@ -163,6 +190,15 @@ See later source ranges for more context. about the sweep`, projectName !== '' && projectName !== 'browser' ? projectName : undefined, kcl_version: kclManager.kclVersion, } + + return submitToApi(body, token) +} + +// Helper function to handle API submission +async function submitToApi( + body: Models['TextToCadIterationBody_type'], + token?: string +): Promise { const url = VITE_KC_API_BASE_URL + '/ml/text-to-cad/iteration' const data: Models['TextToCadIteration_type'] | Error = await crossPlatformFetch( diff --git a/src/lib/selections.ts b/src/lib/selections.ts index 4c9f94520..308abdcc8 100644 --- a/src/lib/selections.ts +++ b/src/lib/selections.ts @@ -481,7 +481,9 @@ export function getSelectionTypeDisplayText( export function canSubmitSelectionArg( selectionsByType: 'none' | Map, - argument: CommandArgument & { inputType: 'selection' } + argument: CommandArgument & { + inputType: 'selection' | 'selectionMixed' + } ) { return ( selectionsByType !== 'none' && diff --git a/src/machines/commandBarMachine.ts b/src/machines/commandBarMachine.ts index e2fe6089f..95e91600b 100644 --- a/src/machines/commandBarMachine.ts +++ b/src/machines/commandBarMachine.ts @@ -295,7 +295,8 @@ export const commandBarMachine = setup({ if ( context.currentArgument && context.selectedCommand && - argConfig?.inputType === 'selection' && + (argConfig?.inputType === 'selection' || + argConfig?.inputType === 'selectionMixed') && argConfig?.validation ) { argConfig