diff --git a/apps/native/.storybook/mocks/tauri-runtime.ts b/apps/native/.storybook/mocks/tauri-runtime.ts index 88bb3264d..9be4410df 100644 --- a/apps/native/.storybook/mocks/tauri-runtime.ts +++ b/apps/native/.storybook/mocks/tauri-runtime.ts @@ -307,10 +307,6 @@ export const storybookTauriAPI = { emit("nix:install:end", { ok: true, code: 0, darwin_rebuild_available: true }); return okResult(); }, - prefetchDarwinRebuild: async () => { - emit("nix:darwin-rebuild:end", { ok: true }); - return okResult(); - }, }, flake: { listHosts: async () => [...defaultHosts], diff --git a/apps/native/.storybook/preview.tsx b/apps/native/.storybook/preview.tsx index f61168163..88afb3ed0 100644 --- a/apps/native/.storybook/preview.tsx +++ b/apps/native/.storybook/preview.tsx @@ -7,13 +7,14 @@ import { useEffect } from "react"; import "./mocks/tauri-runtime"; import "../src/index.css"; -// Replace the widget-store module wholesale with a clamped variant that -// can never drift the nix-setup / permissions / feedback-dialog -// bypasses. The redirect target is `apps/native/src/stores/__mocks__/widget-store.ts`. +// Replace store modules with clamped variants that can never drift the +// nix-setup / permissions bypasses (widget-store) or trigger the feedback +// dialog (feedback-store). Redirect targets live in `src/stores/__mocks__/`. // // Storybook's mocker resolves the path via Node's `require.resolve`, which // doesn't know about `.ts` extensions — so we spell it out. sb.mock(import("../src/stores/widget-store.ts")); +sb.mock(import("../src/stores/feedback-store.ts")); /** * Decorator that applies the dark theme class to the document. diff --git a/apps/native/src-tauri/examples/specta_gen_ts.rs b/apps/native/src-tauri/examples/specta_gen_ts.rs index e865e562f..f57a6243c 100644 --- a/apps/native/src-tauri/examples/specta_gen_ts.rs +++ b/apps/native/src-tauri/examples/specta_gen_ts.rs @@ -90,7 +90,6 @@ fn main() { .register::() .register::() .register::() - .register::() .register::() .register::() .register::() diff --git a/apps/native/src-tauri/src/commands/apply.rs b/apps/native/src-tauri/src/commands/apply.rs index 7ce159557..f87394066 100644 --- a/apps/native/src-tauri/src/commands/apply.rs +++ b/apps/native/src-tauri/src/commands/apply.rs @@ -91,13 +91,6 @@ pub async fn nix_check() -> Result { }) } -#[tauri::command] -pub async fn darwin_rebuild_prefetch(app: AppHandle) -> Result { - nix::prefetch_darwin_rebuild_stream(&app) - .map_err(|e| capture_err("darwin_rebuild_prefetch", e))?; - Ok(shared_types::OkResult::yes()) -} - #[tauri::command] pub async fn nix_install_start(app: AppHandle) -> Result { nix::install_nix_stream(&app).map_err(|e| capture_err("nix_install_start", e))?; diff --git a/apps/native/src-tauri/src/main.rs b/apps/native/src-tauri/src/main.rs index e178f7add..2f06e5a03 100644 --- a/apps/native/src-tauri/src/main.rs +++ b/apps/native/src-tauri/src/main.rs @@ -514,7 +514,6 @@ fn run_gui_mode( commands::evolve_state::routing_state_clear, commands::apply::nix_check, commands::apply::nix_install_start, - commands::apply::darwin_rebuild_prefetch, commands::apply::flake_list_hosts, commands::config::flake_exists, commands::config::bootstrap_default_config, diff --git a/apps/native/src-tauri/src/shared_types/events.rs b/apps/native/src-tauri/src/shared_types/events.rs index c7cb756fe..808dfaf0e 100644 --- a/apps/native/src-tauri/src/shared_types/events.rs +++ b/apps/native/src-tauri/src/shared_types/events.rs @@ -63,16 +63,6 @@ pub struct NixInstallEndEvent { pub error: Option, } -/// Payload for `nix:darwin-rebuild:end`. -#[derive(Debug, Clone, Serialize, Deserialize, Type)] -#[serde(rename_all = "camelCase")] -pub struct NixDarwinRebuildEndEvent { - /// Whether nix-darwin setup completed successfully. - pub ok: bool, - /// Human-readable failure message. - pub error: Option, -} - /// Known rebuild/activation failure categories. #[derive(Debug, Clone, Serialize, Deserialize, Type)] #[serde(rename_all = "snake_case")] diff --git a/apps/native/src-tauri/src/system/nix.rs b/apps/native/src-tauri/src/system/nix.rs index 10056b0ab..f9c13598a 100644 --- a/apps/native/src-tauri/src/system/nix.rs +++ b/apps/native/src-tauri/src/system/nix.rs @@ -162,53 +162,6 @@ pub fn get_nix_version() -> Option { } } -/// Prefetches darwin-rebuild by running `nix build --no-link nix-darwin/master#darwin-rebuild`. -/// This caches the derivation in the nix store so the `nix run` fallback in darwin.rs is fast. -/// Emits `nix:darwin-rebuild:end` with `{ ok: bool, error?: string }` on completion. -pub fn prefetch_darwin_rebuild_stream(app: &AppHandle) -> Result<()> { - info!("[nix] prefetch_darwin_rebuild_stream called"); - - let app_handle = app.clone(); - - // All emit calls below are fire-and-forget: background thread; window may not be - // listening. Tauri emit returns Err only when no listeners are registered. - std::thread::spawn(move || { - let result = Command::new("nix") - .args(["build", "--no-link", "nix-darwin/master#darwin-rebuild"]) - .env("PATH", get_nix_path_with_login_shell()) - .env("NIX_CONFIG", "experimental-features = nix-command flakes") - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .output(); - - match result { - Ok(output) if output.status.success() => { - info!("[nix] darwin-rebuild prefetch succeeded"); - let _ = - app_handle.emit("nix:darwin-rebuild:end", serde_json::json!({ "ok": true })); - } - Ok(output) => { - let stderr = String::from_utf8_lossy(&output.stderr).to_string(); - error!("[nix] darwin-rebuild prefetch failed: {}", stderr); - let _ = app_handle.emit( - "nix:darwin-rebuild:end", - serde_json::json!({ "ok": false, "error": stderr }), - ); - } - Err(e) => { - error!("[nix] darwin-rebuild prefetch error: {}", e); - let _ = app_handle.emit( - "nix:darwin-rebuild:end", - serde_json::json!({ "ok": false, "error": e.to_string() }), - ); - } - } - }); - - info!("[nix] prefetch_darwin_rebuild_stream started background thread"); - Ok(()) -} - pub fn install_nix_stream(app: &AppHandle) -> Result<()> { info!("[nix] install_nix_stream called"); diff --git a/apps/native/src/README.md b/apps/native/src/README.md index 737fe9738..3d0ffb98b 100644 --- a/apps/native/src/README.md +++ b/apps/native/src/README.md @@ -18,9 +18,14 @@ Hooks in `hooks/` sit between components and `ipc/api.ts`. Each hook owns a slic ## App State -`widget-store.ts` is a Zustand store that holds widget step routing, git status, evolve state, UI preferences, and shared flags. Most hooks read from and write to this store. +`stores/` holds four Zustand stores, each scoped to a single concern: -Components read from this store directly when possible to minimize prop-drilling. +- `widget-store.ts` (composed from slices in `stores/slices/`) — backend-mirrored ViewModel state: setup, config, evolve state, git status, rebuild progress, history, summary, console logs. +- `ui-store.ts` — UI navigation and ephemeral interaction state (settings panel, processing flags, evolve prompt draft, prompt history, filesystem/editor view toggles). +- `feedback-store.ts` — error banner + feedback dialog state + captured panic payload. +- `pref-store.ts` — persisted user preferences (confirmation prompts, developer mode, update channel, etc.) — hydrated from / written through to the Tauri prefs store on the Rust side. + +Hooks read from and write to whichever store(s) they need. Components read directly to minimize prop-drilling. ## Preview Indicator diff --git a/apps/native/src/components/widget/controls/bootstrap-config.tsx b/apps/native/src/components/widget/controls/bootstrap-config.tsx index f6c8a4ad7..e9c3b89db 100644 --- a/apps/native/src/components/widget/controls/bootstrap-config.tsx +++ b/apps/native/src/components/widget/controls/bootstrap-config.tsx @@ -3,8 +3,9 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { useDarwinConfig } from "@/hooks/use-darwin-config"; -import { useWidgetStore } from "@/stores/widget-store"; import { tauriAPI } from "@/ipc/api"; +import { useFeedbackStore } from "@/stores/feedback-store"; +import { useWidgetStore } from "@/stores/widget-store"; import { AlertCircle, GitCommit, Sparkles } from "lucide-react"; import { useEffect, useState } from "react"; @@ -27,16 +28,24 @@ export function BootstrapConfig({ label, onSuccess }: BootstrapConfigProps) { setFlakeExists(false); return; } - tauriAPI.flake.existsAt(configDir).then(setFlakeExists).catch(() => setFlakeExists(false)); + tauriAPI.flake + .existsAt(configDir) + .then(setFlakeExists) + .catch(() => setFlakeExists(false)); }, [configDir]); - const needsInitialCommit = flakeExists && (gitStatus === null || gitStatus.headCommitHash === ""); + const needsInitialCommit = + flakeExists && (gitStatus === null || gitStatus.headCommitHash === ""); const message = needsInitialCommit ? "flake.nix found but not committed — Nix needs a git commit to evaluate your flake" : "No nix-darwin configuration found in this directory"; - const buttonLabel = needsInitialCommit ? "Make initial commit" : "Create Default Configuration"; - const loadingLabel = needsInitialCommit ? "Committing..." : "Creating Configuration..."; + const buttonLabel = needsInitialCommit + ? "Make initial commit" + : "Create Default Configuration"; + const loadingLabel = needsInitialCommit + ? "Committing..." + : "Creating Configuration..."; const helpText = needsInitialCommit ? "Stages all files and creates the first commit" : "This will create a basic nix-darwin flake in the directory"; @@ -44,7 +53,7 @@ export function BootstrapConfig({ label, onSuccess }: BootstrapConfigProps) { const handleBootstrap = async (): Promise => { setLocalError(null); await bootstrap(needsInitialCommit ? "" : hostname); - const storeError = useWidgetStore.getState().error; + const storeError = useFeedbackStore.getState().error; if (storeError) { setLocalError(storeError); } else { @@ -86,7 +95,9 @@ export function BootstrapConfig({ label, onSuccess }: BootstrapConfigProps) { onClick={handleBootstrap} className="w-full" data-testid="create-default-config-button" - disabled={(!needsInitialCommit && !hostname.trim()) || isBootstrapping} + disabled={ + (!needsInitialCommit && !hostname.trim()) || isBootstrapping + } > {isBootstrapping ? ( <> @@ -111,4 +122,4 @@ export function BootstrapConfig({ label, onSuccess }: BootstrapConfigProps) { ); -} \ No newline at end of file +} diff --git a/apps/native/src/components/widget/controls/confirm-button.tsx b/apps/native/src/components/widget/controls/confirm-button.tsx index 9af723188..5cd0d6c60 100644 --- a/apps/native/src/components/widget/controls/confirm-button.tsx +++ b/apps/native/src/components/widget/controls/confirm-button.tsx @@ -4,7 +4,7 @@ import { Button } from "@/components/ui/button"; import { CheckConfirmationOff } from "@/components/widget/controls/check-confirmation-off"; import { ConfirmationDialog } from "@/components/widget/controls/confirmation-dialog"; import { usePrefs } from "@/hooks/use-prefs"; -import { useWidgetStore, type ConfirmPrefKey } from "@/stores/widget-store"; +import { usePrefStore, type ConfirmPrefKey } from "@/stores/pref-store"; import type { ComponentProps } from "react"; import { useState } from "react"; @@ -23,7 +23,7 @@ export function ConfirmButton({ children, ...buttonProps }: ConfirmButtonProps) { - const confirm = useWidgetStore((s) => s[confirmPrefKey]); + const confirm = usePrefStore((s) => s[confirmPrefKey]); const { setPref } = usePrefs(); const [open, setOpen] = useState(false); diff --git a/apps/native/src/components/widget/evolve-flow.stories.tsx b/apps/native/src/components/widget/evolve-flow.stories.tsx index bd99f062e..55e81c22c 100644 --- a/apps/native/src/components/widget/evolve-flow.stories.tsx +++ b/apps/native/src/components/widget/evolve-flow.stories.tsx @@ -1,8 +1,14 @@ // @ts-nocheck - Storybook 10 alpha types have inference issues (resolves to `never`) import preview from "#storybook/preview"; +import { useUiStore } from "@/stores/ui-store"; import { useWidgetStore } from "@/stores/widget-store"; -import type { EvolveEvent } from "@/stores/widget-store"; -import type { SemanticChangeMap, EvolveState, GitStatus, Change } from "@/ipc/types"; +import type { + Change, + EvolveEvent, + EvolveState, + GitStatus, + SemanticChangeMap, +} from "@/ipc/types"; import { useEffect, useRef } from "react"; import { DarwinWidget } from "./widget"; @@ -158,7 +164,9 @@ const mockEvolveEvents: EvolveEvent[] = [ function WidgetWithState({ storeState }: { storeState: Record }) { useEffect(() => { - useWidgetStore.setState(storeState); + const { evolvePrompt, ...rest } = storeState; + useWidgetStore.setState(rest); + if (evolvePrompt !== undefined) useUiStore.setState({ evolvePrompt }); }, [storeState]); return ; @@ -170,6 +178,8 @@ function AnimatedEvolveFlow() { useEffect(() => { useWidgetStore.setState({ evolveState: evolveStateBegin, + }); + useUiStore.setState({ evolvePrompt: "Add system monitoring tools like htop and btop", }); diff --git a/apps/native/src/components/widget/feedback/feedback-dialog.tsx b/apps/native/src/components/widget/feedback/feedback-dialog.tsx index df08c3245..149a83c8a 100644 --- a/apps/native/src/components/widget/feedback/feedback-dialog.tsx +++ b/apps/native/src/components/widget/feedback/feedback-dialog.tsx @@ -1,7 +1,8 @@ -import { useEffect, useState } from "react"; import * as Sentry from "@sentry/react"; +import { useEffect, useState } from "react"; -import { useCurrentStep, useWidgetStore } from "@/stores/widget-store"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; import { Dialog, DialogContent, @@ -10,12 +11,9 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; -import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; -import { Checkbox } from "@/components/ui/checkbox"; -import { Textarea } from "@/components/ui/textarea"; -import { Input } from "@/components/ui/input"; import { Select, SelectContent, @@ -23,10 +21,21 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { Lightbulb, Bug, MessageCircle, Info, Loader2 } from "lucide-react"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; -import { Feedback as FeedbackModel, FeedbackType, ShareOptions } from "@/types/feedback"; +import { Textarea } from "@/components/ui/textarea"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; import { tauriAPI } from "@/ipc/api"; +import { useFeedbackStore } from "@/stores/feedback-store"; +import { useCurrentStep } from "@/stores/widget-store"; +import { + Feedback as FeedbackModel, + FeedbackType, + ShareOptions, +} from "@/types/feedback"; +import { Bug, Info, Lightbulb, Loader2, MessageCircle } from "lucide-react"; import { toast } from "sonner"; const DEFAULT_SHARE_OPTIONS: ShareOptions = { @@ -274,22 +283,28 @@ function shouldShowAppLogs( } export function FeedbackDialog() { - const feedbackOpen = useWidgetStore((s) => s.feedbackOpen); - const setFeedbackOpen = useWidgetStore((s) => s.setFeedbackOpen); - const feedbackTypeOverride = useWidgetStore((s) => s.feedbackTypeOverride); - const feedbackInitialText = useWidgetStore((s) => s.feedbackInitialText); - const panicDetails = useWidgetStore((s) => s.panicDetails); - const setFeedbackTypeOverride = useWidgetStore((s) => s.setFeedbackTypeOverride); + const feedbackOpen = useFeedbackStore((s) => s.feedbackOpen); + const setFeedbackOpen = useFeedbackStore((s) => s.setFeedbackOpen); + const feedbackTypeOverride = useFeedbackStore((s) => s.feedbackTypeOverride); + const feedbackInitialText = useFeedbackStore((s) => s.feedbackInitialText); + const panicDetails = useFeedbackStore((s) => s.panicDetails); + const setFeedbackTypeOverride = useFeedbackStore( + (s) => s.setFeedbackTypeOverride, + ); const step = useCurrentStep(); - const mainWindowError = useWidgetStore((s) => s.error) ?? undefined; + const mainWindowError = useFeedbackStore((s) => s.error) ?? undefined; - const [feedbackType, setFeedbackType] = useState(FeedbackType.Suggestion); + const [feedbackType, setFeedbackType] = useState( + FeedbackType.Suggestion, + ); const [feedbackText, setFeedbackText] = useState(""); const [expectedText, setExpectedText] = useState(""); const [email, setEmail] = useState(""); const [promptHistory, setPromptHistory] = useState([]); const [relatedPrompt, setRelatedPrompt] = useState(""); - const [shareOptions, setShareOptions] = useState(DEFAULT_SHARE_OPTIONS); + const [shareOptions, setShareOptions] = useState( + DEFAULT_SHARE_OPTIONS, + ); const [submitting, setSubmitting] = useState(false); const showSentryDebugButton = import.meta.env.DEV; @@ -330,7 +345,10 @@ export function FeedbackDialog() { useEffect(() => { if (!feedbackOpen) return; - if (feedbackType === FeedbackType.Suggestion || feedbackType === FeedbackType.General) { + if ( + feedbackType === FeedbackType.Suggestion || + feedbackType === FeedbackType.General + ) { setShareOptions((prev) => ({ ...prev, evolutionLog: false })); } else if ( feedbackType === FeedbackType.Bug || @@ -351,7 +369,10 @@ export function FeedbackDialog() { setRelatedPrompt(""); setShareOptions(DEFAULT_SHARE_OPTIONS); setFeedbackTypeOverride(null); - useWidgetStore.setState({ feedbackInitialText: null, panicDetails: null }); + useFeedbackStore.setState({ + feedbackInitialText: null, + panicDetails: null, + }); }; const handleSubmit = async () => { @@ -360,9 +381,14 @@ export function FeedbackDialog() { let sentSuccessfully = false; try { - let metadata: Awaited> | null = null; + let metadata: Awaited< + ReturnType + > | null = null; try { - metadata = await tauriAPI.feedback.gatherMetadata(feedbackType, shareOptions); + metadata = await tauriAPI.feedback.gatherMetadata( + feedbackType, + shareOptions, + ); } catch (err) { // eslint-disable-next-line no-console console.warn("Failed to gather feedback metadata:", err); @@ -371,17 +397,21 @@ export function FeedbackDialog() { // Build a typed Feedback model and log it (replace with submission later) const modelType = feedbackType; const relatedPromptText = - (feedbackType === FeedbackType.Issue || feedbackType === FeedbackType.Error) && + (feedbackType === FeedbackType.Issue || + feedbackType === FeedbackType.Error) && relatedPrompt ? relatedPrompt : undefined; - const selectedPromptText = shareOptions.lastPrompt ? relatedPromptText : undefined; + const selectedPromptText = shareOptions.lastPrompt + ? relatedPromptText + : undefined; const feedbackModel = new FeedbackModel({ type: modelType, text: feedbackText, email: email || undefined, - expectedText: feedbackType === FeedbackType.Bug ? expectedText : undefined, + expectedText: + feedbackType === FeedbackType.Bug ? expectedText : undefined, share: shareOptions, // artifact fields left empty for now; will be populated by caller when collecting logs lastPromptText: selectedPromptText, @@ -394,7 +424,10 @@ export function FeedbackDialog() { buildErrorOutput: metadata?.buildErrorOutput ?? undefined, flakeInputsSnapshot: metadata?.flakeInputsSnapshot ?? undefined, appLogsContent: metadata?.appLogsContent ?? undefined, - panicDetails: feedbackType === FeedbackType.Error ? (panicDetails ?? undefined) : undefined, + panicDetails: + feedbackType === FeedbackType.Error + ? (panicDetails ?? undefined) + : undefined, }); const validation = feedbackModel.validate(); @@ -408,7 +441,9 @@ export function FeedbackDialog() { if (sent) { toast.success("Thanks — feedback sent"); } else { - toast.info("Failed to send, we'll try again next time you open the app."); + toast.info( + "Failed to send, we'll try again next time you open the app.", + ); } sentSuccessfully = true; } finally { @@ -449,7 +484,9 @@ export function FeedbackDialog() { console.warn("[debug_sentry_event] Failed to invoke Rust command:", err); } - toast.success(`Sent debug Sentry events${eventId ? ` (frontend: ${eventId})` : ""}`); + toast.success( + `Sent debug Sentry events${eventId ? ` (frontend: ${eventId})` : ""}`, + ); }; const getTextboxLabel = () => { @@ -471,7 +508,11 @@ export function FeedbackDialog() { const isIssue = feedbackType === FeedbackType.Issue; const isError = feedbackType === FeedbackType.Error; const isReportMode = isIssue || isError; - const dialogTitle = isIssue ? "Report an issue" : isError ? "Report an error" : "Give feedback"; + const dialogTitle = isIssue + ? "Report an issue" + : isError + ? "Report an error" + : "Give feedback"; const hasAutoFilledError = feedbackInitialText && isError; const dialogDescription = hasAutoFilledError ? "An error was detected. The details have been pre-filled below. Please review and submit to help us fix this issue." @@ -490,7 +531,9 @@ export function FeedbackDialog() { > - + {hasAutoFilledError && ⚠️} {dialogTitle} @@ -504,23 +547,33 @@ export function FeedbackDialog() { setFeedbackType(value as FeedbackType)} + onValueChange={(value: string) => + setFeedbackType(value as FeedbackType) + } className="grid grid-cols-3 gap-4" >