Skip to content
Open
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
Expand Up @@ -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;
Expand All @@ -18,7 +17,7 @@ export function MacRecommendationChip() {
name: "prompt_suggestion_used",
props: { surface: "mac_recommendation" },
});
uiActions.setEvolvePrompt(recommendation.promptText);
onSelect(recommendation.promptText);
}}
>
{recommendation.promptText}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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("");
};
Expand All @@ -60,7 +62,16 @@ export function PromptHistoryBadge() {
My History
</BadgeButton>
</PopoverTrigger>
<PopoverContent className="w-[400px] p-0" align="start">
<PopoverContent
className="w-[400px] p-0"
align="start"
onCloseAutoFocus={(event) => {
if (!focusPromptAfterCloseRef.current) return;

focusPromptAfterCloseRef.current = false;
event.preventDefault();
}}
>
<Command shouldFilter={false}>
<CommandInput
placeholder="Search history..."
Expand Down
163 changes: 138 additions & 25 deletions apps/native/src/components/widget/promptinput/prompt-input.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import "@testing-library/jest-dom";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import { PromptInput } from "@/components/widget/promptinput/prompt-input";
Expand All @@ -15,6 +15,7 @@ const mocks = vi.hoisted(() => ({
buildCheck: vi.fn<() => Promise<{ passed: boolean }>>(),
getPrefs: vi.fn<() => Promise<Record<string, never>>>(),
checkTools: vi.fn<() => Promise<{ claude: boolean; codex: boolean; opencode: boolean }>>(),
getRecommendedPrompt: vi.fn<() => Promise<null>>(),
}));

vi.mock("@/hooks/use-evolve", () => ({
Expand All @@ -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,
}));
Expand All @@ -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>(),
},
}));

Expand All @@ -69,6 +62,9 @@ vi.mock("@/ipc/api", () => ({
cli: {
checkTools: mocks.checkTools,
},
scanner: {
getRecommendedPrompt: mocks.getRecommendedPrompt,
},
},
}));

Expand All @@ -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() {
Expand All @@ -107,16 +116,26 @@ async function settleProviderValidation() {

describe("<PromptInput>", () => {
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();
});

Expand All @@ -135,7 +154,7 @@ describe("<PromptInput>", () => {
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({
Expand All @@ -155,6 +174,100 @@ describe("<PromptInput>", () => {

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(<PromptInput />);
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(<PromptInput />);
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(<PromptInput />);
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(<PromptInput />);
await settleProviderValidation();

fireEvent.click(screen.getByRole("button", { name: suggestion.label }));

await waitFor(() => {
expect(scrollIntoView).toHaveBeenCalledWith({ behavior: "auto", block: "nearest" });
});
});
});
26 changes: 22 additions & 4 deletions apps/native/src/components/widget/promptinput/prompt-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLTextAreaElement>(null);
const evolvePrompt = useUiState((s) => s.evolvePrompt);
const isProcessing = useUiState((s) => s.isProcessing);
const processingAction = useUiState((s) => s.processingAction);
Expand Down Expand Up @@ -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;
Expand All @@ -152,6 +169,7 @@ export function PromptInput() {
<div className="space-y-3 flex-col min-h-24">
<InputGroup className="bg-background flex-col min-h-24">
<InputGroupTextarea
ref={promptInputRef}
id="evolve-prompt-input"
data-testid="evolve-prompt-input"
disabled={isLoading}
Expand Down Expand Up @@ -240,12 +258,12 @@ export function PromptInput() {
{suggestion.label}
</BadgeButton>
))}
<MacRecommendationChip />
<MacRecommendationChip onSelect={seedPrompt} />
<SystemDefaultsCTA />
<HomebrewBadge />
</div>
<div className="ml-auto shrink-0">
<PromptHistoryBadge />
<PromptHistoryBadge onSelect={seedPrompt} />
</div>
</div>
</div>
Expand Down
Loading