diff --git a/apps/native/src/components/widget/promptinput/mac-recommendation-chip.tsx b/apps/native/src/components/widget/promptinput/mac-recommendation-chip.tsx index ce6bd8bda..f99d47d76 100644 --- a/apps/native/src/components/widget/promptinput/mac-recommendation-chip.tsx +++ b/apps/native/src/components/widget/promptinput/mac-recommendation-chip.tsx @@ -3,9 +3,8 @@ import { BadgeButton } from "@/components/ui/badge-button"; import { useRecommendedPrompt } from "@/hooks/use-recommended-prompt"; import { getTelemetry } from "@/lib/telemetry/instance"; -import { uiActions } from "@nixmac/state"; -export function MacRecommendationChip() { +export function MacRecommendationChip({ onSelect }: { onSelect: (prompt: string) => void }) { const { recommendation } = useRecommendedPrompt(); if (!recommendation) return null; @@ -18,7 +17,7 @@ export function MacRecommendationChip() { name: "prompt_suggestion_used", props: { surface: "mac_recommendation" }, }); - uiActions.setEvolvePrompt(recommendation.promptText); + onSelect(recommendation.promptText); }} > {recommendation.promptText} diff --git a/apps/native/src/components/widget/promptinput/prompt-history-badge.tsx b/apps/native/src/components/widget/promptinput/prompt-history-badge.tsx index 0a43f65ae..85c8a97ff 100644 --- a/apps/native/src/components/widget/promptinput/prompt-history-badge.tsx +++ b/apps/native/src/components/widget/promptinput/prompt-history-badge.tsx @@ -12,14 +12,15 @@ import { import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { getTelemetry } from "@/lib/telemetry/instance"; import { cn } from "@/lib/utils"; -import { uiActions, useUiState, useViewModel } from "@nixmac/state"; +import { useUiState, useViewModel } from "@nixmac/state"; import { ClockIcon } from "lucide-react"; -import { useState } from "react"; +import { useRef, useState } from "react"; -export function PromptHistoryBadge() { +export function PromptHistoryBadge({ onSelect }: { onSelect: (prompt: string) => void }) { + const focusPromptAfterCloseRef = useRef(false); const history = useViewModel((s) => s.promptHistory); const evolvePrompt = useUiState((s) => s.evolvePrompt); - const isProcessing = useUiState((s) => s.isProcessing); + const isProcessing = useUiState((s) => s.isProcessing); const processingAction = useUiState((s) => s.processingAction); const disabled = isProcessing && processingAction === "evolve"; @@ -36,7 +37,8 @@ export function PromptHistoryBadge() { name: "prompt_suggestion_used", props: { surface: "history" }, }); - uiActions.setEvolvePrompt(prompt); + focusPromptAfterCloseRef.current = true; + onSelect(prompt); setOpen(false); setSearchValue(""); }; @@ -60,7 +62,16 @@ export function PromptHistoryBadge() { My History - + { + if (!focusPromptAfterCloseRef.current) return; + + focusPromptAfterCloseRef.current = false; + event.preventDefault(); + }} + > ({ buildCheck: vi.fn<() => Promise<{ passed: boolean }>>(), getPrefs: vi.fn<() => Promise>>(), checkTools: vi.fn<() => Promise<{ claude: boolean; codex: boolean; opencode: boolean }>>(), + getRecommendedPrompt: vi.fn<() => Promise>(), })); vi.mock("@/hooks/use-evolve", () => ({ @@ -25,18 +26,10 @@ vi.mock("@/hooks/use-evolve", () => ({ }), })); -vi.mock("@/components/widget/promptinput/mac-recommendation-chip", () => ({ - MacRecommendationChip: () => null, -})); - vi.mock("@/components/widget/promptinput/homebrew-badge", () => ({ HomebrewBadge: () => null, })); -vi.mock("@/components/widget/promptinput/prompt-history-badge", () => ({ - PromptHistoryBadge: () => null, -})); - vi.mock("@/components/widget/promptinput/system-defaults-cta", () => ({ SystemDefaultsCTA: () => null, })); @@ -45,9 +38,9 @@ vi.mock("@/components/widget/promptinput/system-defaults-cta", () => ({ // (router.tsx → DarwinWidget → EditorPanel → monaco, which needs matchMedia). vi.mock("@/router", () => ({ nav: { - openSettings: vi.fn(), - goHome: vi.fn(), - closeSettings: vi.fn(), + openSettings: vi.fn<() => void>(), + goHome: vi.fn<() => void>(), + closeSettings: vi.fn<() => void>(), }, })); @@ -69,6 +62,9 @@ vi.mock("@/ipc/api", () => ({ cli: { checkTools: mocks.checkTools, }, + scanner: { + getRecommendedPrompt: mocks.getRecommendedPrompt, + }, }, })); @@ -83,19 +79,32 @@ const dirtyGitStatus: GitStatus = { changes: [], }; +const scrollIntoView = vi.fn<(options?: ScrollIntoViewOptions | boolean) => void>(); +const originalScrollIntoView = Element.prototype.scrollIntoView; + +class ResizeObserverStub { + observe() {} + unobserve() {} + disconnect() {} +} + function resetStore() { - uiActions.setEvolvePrompt(""); - viewModelActions.setState({ - git: null, - evolve: null, - build: { - externalBuildDetected: false, - upstreamUpdateAvailable: false, - rebuildNeeded: false, - }, - preferences: makeGlobalPreferences(), + act(() => { + uiActions.setEvolvePrompt(""); + uiActions.setRecommendedPrompt(null); + viewModelActions.setState({ + git: null, + evolve: null, + build: { + externalBuildDetected: false, + upstreamUpdateAvailable: false, + rebuildNeeded: false, + }, + preferences: makeGlobalPreferences(), + promptHistory: [], + }); + uiActions.setProcessing(false); }); - uiActions.setProcessing(false); } async function settleProviderValidation() { @@ -107,16 +116,26 @@ async function settleProviderValidation() { describe("", () => { beforeEach(() => { + Element.prototype.scrollIntoView = scrollIntoView; + vi.stubGlobal("ResizeObserver", ResizeObserverStub); resetStore(); mocks.handleEvolve.mockResolvedValue(); mocks.evolveFromManual.mockResolvedValue(); mocks.buildCheck.mockResolvedValue({ passed: true }); mocks.getPrefs.mockResolvedValue({}); mocks.checkTools.mockResolvedValue({ claude: false, codex: false, opencode: false }); + mocks.getRecommendedPrompt.mockResolvedValue(null); }); afterEach(() => { resetStore(); + scrollIntoView.mockReset(); + if (originalScrollIntoView) { + Element.prototype.scrollIntoView = originalScrollIntoView; + } else { + Reflect.deleteProperty(Element.prototype, "scrollIntoView"); + } + vi.unstubAllGlobals(); vi.clearAllMocks(); }); @@ -135,7 +154,7 @@ describe("", () => { expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); }); - it("seeds a full starter prompt from the curated chips", async () => { + it("reveals and focuses a seeded starter prompt without submitting it", async () => { // The default suggestion variant is `spotlight`; force `chips` via the // developer override so the curated chips render for this scenario. viewModelActions.patch({ @@ -155,6 +174,100 @@ describe("", () => { fireEvent.click(chip); - expect(screen.getByTestId("evolve-prompt-input")).toHaveValue(suggestion.prompt); + const input = screen.getByTestId("evolve-prompt-input") as HTMLTextAreaElement; + + await waitFor(() => { + expect(input).toHaveValue(suggestion.prompt); + expect(scrollIntoView).toHaveBeenCalledWith({ behavior: "smooth", block: "nearest" }); + expect(input).toHaveFocus(); + }); + expect(input.selectionStart).toBe(suggestion.prompt.length); + expect(input.selectionEnd).toBe(suggestion.prompt.length); + expect(mocks.handleEvolve).not.toHaveBeenCalled(); + }); + + it("keeps focus on a prompt selected from history after the popover closes", async () => { + const historyPrompt = "Show all file extensions in Finder"; + viewModelActions.setState({ promptHistory: [historyPrompt] }); + + render(); + await settleProviderValidation(); + + fireEvent.click(screen.getByRole("button", { name: "My History" })); + fireEvent.click(await screen.findByText(historyPrompt)); + + const input = screen.getByTestId("evolve-prompt-input") as HTMLTextAreaElement; + await waitFor(() => { + expect(screen.queryByPlaceholderText("Search history...")).not.toBeInTheDocument(); + expect(input).toHaveValue(historyPrompt); + expect(scrollIntoView).toHaveBeenCalledWith({ behavior: "smooth", block: "nearest" }); + expect(input).toHaveFocus(); + }); + expect(input.selectionStart).toBe(historyPrompt.length); + expect(input.selectionEnd).toBe(historyPrompt.length); + expect(mocks.handleEvolve).not.toHaveBeenCalled(); + }); + + it("restores focus to the history trigger when the popover closes without a selection", async () => { + viewModelActions.setState({ promptHistory: ["Show all file extensions in Finder"] }); + + render(); + await settleProviderValidation(); + + const trigger = screen.getByRole("button", { name: "My History" }); + fireEvent.click(trigger); + + const search = await screen.findByPlaceholderText("Search history..."); + await waitFor(() => expect(search).toHaveFocus()); + fireEvent.keyDown(search, { key: "Escape" }); + + await waitFor(() => { + expect(screen.queryByPlaceholderText("Search history...")).not.toBeInTheDocument(); + expect(trigger).toHaveFocus(); + }); + }); + + it("routes the Mac recommendation through the same seed behavior", async () => { + const recommendation = { + id: "finder-extensions", + promptText: "Show all file extensions in Finder", + }; + uiActions.setRecommendedPrompt(recommendation); + + render(); + await settleProviderValidation(); + + fireEvent.click(screen.getByRole("button", { name: recommendation.promptText })); + + const input = screen.getByTestId("evolve-prompt-input") as HTMLTextAreaElement; + await waitFor(() => { + expect(input).toHaveValue(recommendation.promptText); + expect(scrollIntoView).toHaveBeenCalled(); + expect(input).toHaveFocus(); + }); + expect(input.selectionStart).toBe(recommendation.promptText.length); + expect(input.selectionEnd).toBe(recommendation.promptText.length); + expect(mocks.handleEvolve).not.toHaveBeenCalled(); + }); + + it("avoids smooth scrolling when reduced motion is preferred", async () => { + vi.stubGlobal("matchMedia", vi.fn().mockReturnValue({ matches: true })); + viewModelActions.patch({ + preferences: makeGlobalPreferences({ + featureFlagOverrides: { [EVOLVE_PROMPT_SUGGESTIONS_FLAG]: "chips" }, + }), + }); + + const suggestion = STARTER_PROMPT_CHIPS.find(({ id }) => id === "dev-terminal"); + if (!suggestion) throw new Error("Expected dev-terminal starter prompt"); + + render(); + await settleProviderValidation(); + + fireEvent.click(screen.getByRole("button", { name: suggestion.label })); + + await waitFor(() => { + expect(scrollIntoView).toHaveBeenCalledWith({ behavior: "auto", block: "nearest" }); + }); }); }); diff --git a/apps/native/src/components/widget/promptinput/prompt-input.tsx b/apps/native/src/components/widget/promptinput/prompt-input.tsx index 7dd58f212..4231495a1 100644 --- a/apps/native/src/components/widget/promptinput/prompt-input.tsx +++ b/apps/native/src/components/widget/promptinput/prompt-input.tsx @@ -30,11 +30,12 @@ import { getTelemetry } from "@/lib/telemetry/instance"; import { nav } from "@/router"; import { uiActions, useUiState, useViewModel } from "@nixmac/state"; import { ArrowUpIcon } from "lucide-react"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; const MAX_CONTEXT_LENGTH = 1000; export function PromptInput() { + const promptInputRef = useRef(null); const evolvePrompt = useUiState((s) => s.evolvePrompt); const isProcessing = useUiState((s) => s.isProcessing); const processingAction = useUiState((s) => s.processingAction); @@ -141,7 +142,23 @@ export function PromptInput() { // PostHog-flag-driven suggestion surface under the input. const suggestionsVariant = usePromptSuggestionsVariant(); - const seedPrompt = (prompt: string) => uiActions.setEvolvePrompt(prompt); + const seedPrompt = (prompt: string) => { + uiActions.setEvolvePrompt(prompt); + + requestAnimationFrame(() => { + const input = promptInputRef.current; + if (!input || input.disabled) return; + + const prefersReducedMotion = + window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false; + input.scrollIntoView({ + behavior: prefersReducedMotion ? "auto" : "smooth", + block: "nearest", + }); + input.focus({ preventScroll: true }); + input.setSelectionRange(input.value.length, input.value.length); + }); + }; const words = evolvePrompt.split(" ").length; const percentage = words / MAX_CONTEXT_LENGTH; @@ -152,6 +169,7 @@ export function PromptInput() {
))} - +
- +