diff --git a/apps/native/src-tauri/examples/specta_gen_ts.rs b/apps/native/src-tauri/examples/specta_gen_ts.rs index f68818b1..3c9706ff 100644 --- a/apps/native/src-tauri/examples/specta_gen_ts.rs +++ b/apps/native/src-tauri/examples/specta_gen_ts.rs @@ -53,7 +53,8 @@ fn main() { .register::() .register::() .register::() - .register::(); + .register::() + .register::(); let shared_output_path = "../src/types/shared.ts"; diff --git a/apps/native/src-tauri/src/commands.rs b/apps/native/src-tauri/src/commands.rs index 6af60ab3..ba707bc0 100644 --- a/apps/native/src-tauri/src/commands.rs +++ b/apps/native/src-tauri/src/commands.rs @@ -717,12 +717,19 @@ pub async fn generate_history_from( /// Summarizes the current working state, running the from-scratch pipeline if /// no existing summaries are found, or grouping and simplifying existing ones. +/// Returns the updated SemanticChangeMap so the frontend can apply it immediately. #[tauri::command] -pub async fn summarize_current(app: AppHandle) -> Result<(), String> { +pub async fn summarize_current( + app: AppHandle, +) -> Result { crate::summarize::new_changeset(&app, None) .await - .map(|_| ()) - .map_err(|e| capture_err("summarize_current", e)) + .map_err(|e| capture_err("summarize_current", e))?; + let db_path = db::get_db_path(&app).map_err(|e| capture_err("summarize_current", e))?; + let dir = store::get_config_dir(&app).map_err(|e| capture_err("summarize_current", e))?; + let change_sets = crate::summarize::find_existing::for_current_state(&db_path, &dir) + .map_err(|e| capture_err("summarize_current", e))?; + Ok(crate::summarize::group_existing::from_change_sets(change_sets)) } /// Returns all commits on the main branch, each paired with optional DB metadata, summary, @@ -809,6 +816,9 @@ pub async fn ui_get_prefs(app: AppHandle) -> Result { .map_err(|e| capture_err("ui_get_prefs", e))?; let confirm_rollback = store::get_bool_pref(&app, store::CONFIRM_ROLLBACK_KEY, true) .map_err(|e| capture_err("ui_get_prefs", e))?; + let auto_summarize_on_focus = + store::get_bool_pref(&app, store::AUTO_SUMMARIZE_ON_FOCUS_KEY, false) + .map_err(|e| capture_err("ui_get_prefs", e))?; Ok(types::UiPrefs { openrouter_api_key, @@ -830,6 +840,7 @@ pub async fn ui_get_prefs(app: AppHandle) -> Result { confirm_build, confirm_clear, confirm_rollback, + auto_summarize_on_focus, }) } @@ -906,6 +917,13 @@ pub async fn ui_set_prefs( store::set_bool_pref(&app, store::CONFIRM_ROLLBACK_KEY, confirm_rollback) .map_err(|e| capture_err("ui_set_prefs", e))?; } + if let Some(auto_summarize_on_focus) = prefs + .get(store::AUTO_SUMMARIZE_ON_FOCUS_KEY) + .and_then(|v| v.as_bool()) + { + store::set_bool_pref(&app, store::AUTO_SUMMARIZE_ON_FOCUS_KEY, auto_summarize_on_focus) + .map_err(|e| capture_err("ui_set_prefs", e))?; + } Ok(serde_json::json!({"ok": true})) } diff --git a/apps/native/src-tauri/src/shared_types.rs b/apps/native/src-tauri/src/shared_types.rs index 443087ba..a922fb29 100644 --- a/apps/native/src-tauri/src/shared_types.rs +++ b/apps/native/src-tauri/src/shared_types.rs @@ -262,3 +262,29 @@ pub struct EvolutionFailureResult { pub git_status: Option, pub telemetry: EvolutionTelemetry, } + +// ============================================================================= +// UI Preferences +// ============================================================================= + +/// User interface preferences (synced to settings.json via tauri-plugin-store). +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct UiPrefs { + pub openrouter_api_key: Option, + pub openai_api_key: Option, + pub ollama_api_base_url: Option, + pub vllm_api_base_url: Option, + pub vllm_api_key: Option, + pub summary_provider: Option, + pub summary_model: Option, + pub evolve_provider: Option, + pub evolve_model: Option, + pub max_iterations: Option, + pub max_build_attempts: Option, + pub send_diagnostics: bool, + pub confirm_build: bool, + pub confirm_clear: bool, + pub confirm_rollback: bool, + pub auto_summarize_on_focus: bool, +} diff --git a/apps/native/src-tauri/src/store.rs b/apps/native/src-tauri/src/store.rs index 647cca65..42ed4baa 100644 --- a/apps/native/src-tauri/src/store.rs +++ b/apps/native/src-tauri/src/store.rs @@ -12,11 +12,18 @@ use tauri_plugin_store::{Store, StoreExt}; const STORE_PATH: &str = "settings.json"; -// Confirmation dialog preference keys (shared between store and commands) +// ============================================================================= +// Preference Tab Keys +// ============================================================================= + +// Confirmation dialog preference keys pub const CONFIRM_BUILD_KEY: &str = "confirmBuild"; pub const CONFIRM_CLEAR_KEY: &str = "confirmClear"; pub const CONFIRM_ROLLBACK_KEY: &str = "confirmRollback"; +// Summarization preference keys +pub const AUTO_SUMMARIZE_ON_FOCUS_KEY: &str = "autoSummarizeOnFocus"; + pub const DEFAULT_MAX_ITERATIONS: usize = 25; /// Gets a handle to the settings store. diff --git a/apps/native/src-tauri/src/types.rs b/apps/native/src-tauri/src/types.rs index 8df9d658..f6daef10 100644 --- a/apps/native/src-tauri/src/types.rs +++ b/apps/native/src-tauri/src/types.rs @@ -21,71 +21,7 @@ pub struct Config { pub host_attr: Option, } -pub use crate::shared_types::{GitFileStatus, GitStatus}; - -/// User interface preferences. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct UiPrefs { - /// OpenRouter API key for AI features via OpenRouter. - #[serde(rename = "openrouterApiKey")] - pub openrouter_api_key: Option, - - /// OpenAI API key for direct OpenAI access. - #[serde(rename = "openaiApiKey")] - pub openai_api_key: Option, - - /// Ollama API base URL for local model access. - #[serde(rename = "ollamaApiBaseUrl")] - pub ollama_api_base_url: Option, - - /// vLLM API base URL (OpenAI-compatible endpoint). - #[serde(rename = "vllmApiBaseUrl")] - pub vllm_api_base_url: Option, - - /// vLLM API key (optional — defaults to "none" if not set). - #[serde(rename = "vllmApiKey")] - pub vllm_api_key: Option, - - /// Provider for summarization (openai/ollama/vllm). - #[serde(rename = "summaryProvider")] - pub summary_provider: Option, - - /// Model name for summarization. - #[serde(rename = "summaryModel")] - pub summary_model: Option, - - /// Provider for evolution (openai/ollama). - #[serde(rename = "evolveProvider")] - pub evolve_provider: Option, - - /// Model name for evolution. - #[serde(rename = "evolveModel")] - pub evolve_model: Option, - - /// Maximum iterations for evolution before giving up. - #[serde(rename = "maxIterations")] - pub max_iterations: Option, - - /// Maximum build attempts for evolution before giving up. - #[serde(rename = "maxBuildAttempts")] - pub max_build_attempts: Option, - - /// Whether to send diagnostics to the nixmac team. - #[serde(rename = "sendDiagnostics")] - pub send_diagnostics: bool, - - /// Whether to show a confirmation dialog before building. - #[serde(rename = "confirmBuild")] - pub confirm_build: bool, - - /// Whether to show a confirmation dialog before clearing/discarding. - #[serde(rename = "confirmClear")] - pub confirm_clear: bool, - - /// Whether to show a confirmation dialog before rolling back. - #[serde(rename = "confirmRollback")] - pub confirm_rollback: bool, -} +pub use crate::shared_types::{GitFileStatus, GitStatus, UiPrefs}; /// Result of a darwin-rebuild operation. #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/apps/native/src/components/widget/analyze-history-button.tsx b/apps/native/src/components/widget/analyze-history-button.tsx index aad342c4..339fc910 100644 --- a/apps/native/src/components/widget/analyze-history-button.tsx +++ b/apps/native/src/components/widget/analyze-history-button.tsx @@ -1,13 +1,15 @@ -import { Dna, Square } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { useWidgetStore } from "@/stores/widget-store"; +import { AnalyzeButton } from "@/components/widget/summaries/analyze-button"; import { useHistory } from "@/hooks/use-history"; - +import { useWidgetStore } from "@/stores/widget-store"; +import { Dna, Square } from "lucide-react"; // Generates commit (if need be) and summary metadata for history items that are missing it. export function AnalyzeHistoryButton() { const history = useWidgetStore((state) => state.history); - const analyzingSize = useWidgetStore((state) => state.analyzingHistoryForHashes.size); + const analyzingSize = useWidgetStore( + (state) => state.analyzingHistoryForHashes.size, + ); const { analyzeMany, stopAnalyzing } = useHistory(); const recentUnsummarizedHashes = history @@ -33,17 +35,13 @@ export function AnalyzeHistoryButton() { } if (recentUnsummarizedHashes.length > 0) { return ( - + ); } diff --git a/apps/native/src/components/widget/analyze-history-item-button.tsx b/apps/native/src/components/widget/analyze-history-item-button.tsx index 8ba93756..43b9cf40 100644 --- a/apps/native/src/components/widget/analyze-history-item-button.tsx +++ b/apps/native/src/components/widget/analyze-history-item-button.tsx @@ -1,28 +1,30 @@ -import { useState } from "react"; -import { Dna, Loader2 } from "lucide-react"; -import { Button } from "@/components/ui/button"; -import { cn } from "@/lib/utils"; -import { useWidgetStore } from "@/stores/widget-store"; +import { AnalyzeButton } from "@/components/widget/summaries/analyze-button"; import { useHistory } from "@/hooks/use-history"; +import { useWidgetStore } from "@/stores/widget-store"; +import { Dna, Loader2 } from "lucide-react"; +import { useState } from "react"; interface AnalyzeHistoryItemButtonProps { hash: string; isPartial?: boolean; className?: string; } -export function AnalyzeHistoryItemButton({ hash, isPartial, className }: AnalyzeHistoryItemButtonProps) { +export function AnalyzeHistoryItemButton({ + hash, + isPartial, + className, +}: AnalyzeHistoryItemButtonProps) { const [localAnalyzing, setLocalAnalyzing] = useState(false); - const queuedByMany = useWidgetStore((state) => state.analyzingHistoryForHashes.has(hash)); + const queuedByMany = useWidgetStore((state) => + state.analyzingHistoryForHashes.has(hash), + ); const isAnalyzing = localAnalyzing || queuedByMany; const { analyzeOne } = useHistory(); return ( - + ); } diff --git a/apps/native/src/components/widget/settings/preferences-tab.tsx b/apps/native/src/components/widget/settings/preferences-tab.tsx index 5734b80e..daeb778c 100644 --- a/apps/native/src/components/widget/settings/preferences-tab.tsx +++ b/apps/native/src/components/widget/settings/preferences-tab.tsx @@ -7,6 +7,7 @@ export function PreferencesTab() { const confirmBuild = useWidgetStore((s) => s.confirmBuild); const confirmClear = useWidgetStore((s) => s.confirmClear); const confirmRollback = useWidgetStore((s) => s.confirmRollback); + const autoSummarizeOnFocus = useWidgetStore((s) => s.autoSummarizeOnFocus); return (
@@ -48,6 +49,23 @@ export function PreferencesTab() {
+
+
+
Summarization
+
+
+
+
Auto-summarize on focus
+
Summarize unsummarized changes when the window is focused
+
+ setPref("autoSummarizeOnFocus", checked)} + /> +
+
+
+
); } diff --git a/apps/native/src/components/widget/summaries/__snapshots__/analyze-button.stories.tsx.snap b/apps/native/src/components/widget/summaries/__snapshots__/analyze-button.stories.tsx.snap new file mode 100644 index 00000000..7995b46f --- /dev/null +++ b/apps/native/src/components/widget/summaries/__snapshots__/analyze-button.stories.tsx.snap @@ -0,0 +1,9 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Idle 1`] = `""`; + +exports[`Loading 1`] = `""`; + +exports[`Update 1`] = `""`; + +exports[`With Count 1`] = `""`; diff --git a/apps/native/src/components/widget/summaries/__snapshots__/unsummarized-changes-section.stories.tsx.snap b/apps/native/src/components/widget/summaries/__snapshots__/unsummarized-changes-section.stories.tsx.snap new file mode 100644 index 00000000..98141238 --- /dev/null +++ b/apps/native/src/components/widget/summaries/__snapshots__/unsummarized-changes-section.stories.tsx.snap @@ -0,0 +1,9 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Also Unsummarized 1`] = `"
Also innixpkgs:
modules/darwin/packages.nix
modules/darwin/fonts.nix
modules/home/shell.nix
modules/darwin/terminal.nix
"`; + +exports[`Only Unsummarized 1`] = `"
Manual Changes found innixpkgs:
modules/darwin/packages.nix
modules/darwin/fonts.nix
modules/home/shell.nix
modules/darwin/terminal.nix
"`; + +exports[`Single 1`] = `"
Manual Changes found innixpkgs:
flake.nix
"`; + +exports[`With Rename 1`] = `"
Manual Changes found innixpkgs:
modules/darwin/packages.nix
modules/darwin/homebrew.nixmodules/darwin/brew.nix
home.nix
"`; diff --git a/apps/native/src/components/widget/summaries/analyze-button.stories.tsx b/apps/native/src/components/widget/summaries/analyze-button.stories.tsx new file mode 100644 index 00000000..57b6cb67 --- /dev/null +++ b/apps/native/src/components/widget/summaries/analyze-button.stories.tsx @@ -0,0 +1,49 @@ +// @ts-nocheck - Storybook 10 alpha types have inference issues (resolves to `never`) +import preview from "#storybook/preview"; +import { Dna, Loader2 } from "lucide-react"; +import { AnalyzeButton } from "./analyze-button"; + +const meta = preview.meta({ + title: "Widget/Summaries/AnalyzeButton", + component: AnalyzeButton, + parameters: { layout: "centered" }, + tags: ["autodocs"], +}); + +export default meta; + +export const Idle = meta.story({ + render: () => ( + {}}> + + Analyze + + ), +}); + +export const Loading = meta.story({ + render: () => ( + + + Analyzing… + + ), +}); + +export const WithCount = meta.story({ + render: () => ( + {}}> + + Analyze recent (3) + + ), +}); + +export const Update = meta.story({ + render: () => ( + {}}> + + Update + + ), +}); diff --git a/apps/native/src/components/widget/summaries/analyze-button.tsx b/apps/native/src/components/widget/summaries/analyze-button.tsx new file mode 100644 index 00000000..9f85cf1c --- /dev/null +++ b/apps/native/src/components/widget/summaries/analyze-button.tsx @@ -0,0 +1,31 @@ +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import type { MouseEventHandler, ReactNode } from "react"; + +export function AnalyzeButton({ + onClick, + disabled, + children, + className, +}: { + onClick?: MouseEventHandler; + disabled?: boolean; + children: ReactNode; + className?: string; +}) { + return ( + + ); +} diff --git a/apps/native/src/components/widget/summaries/analyze-current-button.tsx b/apps/native/src/components/widget/summaries/analyze-current-button.tsx new file mode 100644 index 00000000..6d0ac4ea --- /dev/null +++ b/apps/native/src/components/widget/summaries/analyze-current-button.tsx @@ -0,0 +1,27 @@ +"use client"; + +import { AnalyzeButton } from "@/components/widget/summaries/analyze-button"; +import { useSummary } from "@/hooks/use-summary"; +import { useWidgetStore } from "@/stores/widget-store"; +import { Dna, Loader2 } from "lucide-react"; + +export function AnalyzeCurrentButton() { + const isSummarizing = useWidgetStore((s) => s.isSummarizing); + const { generateCurrentSummary } = useSummary(); + + return ( + + {isSummarizing ? ( + <> + + Analyzing… + + ) : ( + <> + + Analyze + + )} + + ); +} diff --git a/apps/native/src/components/widget/summaries/diff.tsx b/apps/native/src/components/widget/summaries/diff.tsx index 951717b9..ccd98886 100644 --- a/apps/native/src/components/widget/summaries/diff.tsx +++ b/apps/native/src/components/widget/summaries/diff.tsx @@ -1,6 +1,6 @@ "use client"; -import { ChevronRight, FileCode, Pencil } from "lucide-react"; +import { ChevronRight, Pencil } from "lucide-react"; import type { BundledLanguage } from "shiki"; import { CodeBlock, @@ -15,11 +15,10 @@ import { } from "@/components/ui/collapsible"; import { ScrollArea } from "@/components/ui/scroll-area"; import { useWidgetStore } from "@/stores/widget-store"; -import type { Change } from "@/types/shared"; -import { getDirectory, getShortFilename } from "@/components/widget/utils"; +import { CHANGE_TYPE_STYLES, getDirectory, getShortFilename, type ChangeWithRichType } from "@/components/widget/utils"; interface DiffProps { - changes: Change[]; + changes: ChangeWithRichType[]; } export function Diff({ changes }: DiffProps) { @@ -35,6 +34,9 @@ export function Diff({ changes }: DiffProps) {
{changes.map((change, index) => { + const { icon: Icon, iconColor } = CHANGE_TYPE_STYLES[change.changeType]; + const dir = getDirectory(change.filename); + const name = getShortFilename(change.filename); const codeData = [ { language: "diff", @@ -54,16 +56,12 @@ export function Diff({ changes }: DiffProps) { - -
- - {getShortFilename(change.filename)} + +
+ + {dir && {dir}/} + {name} - {getDirectory(change.filename) && ( - - {getDirectory(change.filename)} - - )}
); } diff --git a/apps/native/src/components/widget/utils.ts b/apps/native/src/components/widget/utils.ts index fc0aa351..d2251131 100644 --- a/apps/native/src/components/widget/utils.ts +++ b/apps/native/src/components/widget/utils.ts @@ -1,7 +1,5 @@ -import type { - WidgetState, - WidgetStep, -} from "@/stores/widget-store"; +import type { WidgetState, WidgetStep } from "@/stores/widget-store"; +import { FilePen, FilePlus, FileX, FileCode, type LucideIcon } from "lucide-react"; export function computeCurrentStep(state: WidgetState): WidgetStep { const hasConfigDir = !!state.configDir; @@ -49,8 +47,12 @@ export function getDirectory(path: string): string { /** * Infer change type from diff chunk content. */ -export function getChangeTypeFromChunks(chunks: string): "new" | "edited" | "removed" { - const contentLines = chunks.split("\n").filter((l) => l.startsWith("+") || l.startsWith("-")); +export function getChangeTypeFromChunks( + chunks: string, +): "new" | "edited" | "removed" { + const contentLines = chunks + .split("\n") + .filter((l) => l.startsWith("+") || l.startsWith("-")); if (contentLines.length === 0) return "edited"; const hasAdditions = contentLines.some((l) => l.startsWith("+")); const hasDeletions = contentLines.some((l) => l.startsWith("-")); @@ -59,9 +61,8 @@ export function getChangeTypeFromChunks(chunks: string): "new" | "edited" | "rem return "edited"; } - // ============================================================================= -// CATEGORY COLORS +// SUMMARY CATEGORY COLORS // ============================================================================= export type CategoryStyle = { @@ -71,19 +72,64 @@ export type CategoryStyle = { border: string; }; -const EMERALD: CategoryStyle = { text: "text-emerald-500", bg: "bg-emerald-500/[0.08]", dot: "bg-emerald-500", border: "border-emerald-500/40" }; -const BLUE: CategoryStyle = { text: "text-blue-500", bg: "bg-blue-500/[0.08]", dot: "bg-blue-500", border: "border-blue-500/40" }; -const AMBER: CategoryStyle = { text: "text-amber-500", bg: "bg-amber-500/[0.08]", dot: "bg-amber-500", border: "border-amber-500/40" }; -const VIOLET: CategoryStyle = { text: "text-violet-500", bg: "bg-violet-500/[0.08]", dot: "bg-violet-500", border: "border-violet-500/40" }; -const GRAY: CategoryStyle = { text: "text-gray-500", bg: "bg-gray-500/[0.08]", dot: "bg-gray-500", border: "border-gray-500/40" }; +const EMERALD: CategoryStyle = { + text: "text-emerald-500", + bg: "bg-emerald-500/[0.08]", + dot: "bg-emerald-500", + border: "border-emerald-500/40", +}; +const BLUE: CategoryStyle = { + text: "text-blue-500", + bg: "bg-blue-500/[0.08]", + dot: "bg-blue-500", + border: "border-blue-500/40", +}; +const AMBER: CategoryStyle = { + text: "text-amber-500", + bg: "bg-amber-500/[0.08]", + dot: "bg-amber-500", + border: "border-amber-500/40", +}; +const VIOLET: CategoryStyle = { + text: "text-violet-500", + bg: "bg-violet-500/[0.08]", + dot: "bg-violet-500", + border: "border-violet-500/40", +}; +const GRAY: CategoryStyle = { + text: "text-gray-500", + bg: "bg-gray-500/[0.08]", + dot: "bg-gray-500", + border: "border-gray-500/40", +}; const CATEGORY_PALETTE: CategoryStyle[] = [EMERALD, BLUE, AMBER, VIOLET, GRAY]; const KEYWORD_STYLES: Array<{ keywords: string[]; style: CategoryStyle }> = [ - { keywords: ["config", "settings", "option", "nix", "darwin", "home", "profile"], style: EMERALD }, - { keywords: ["service", "system", "network", "daemon", "module"], style: BLUE }, - { keywords: ["package", "program", "install", "app", "plugin", "tool"], style: AMBER }, - { keywords: ["theme", "visual", "color", "style", "font", "ui", "appearance"], style: VIOLET }, + { + keywords: [ + "config", + "settings", + "option", + "nix", + "darwin", + "home", + "profile", + ], + style: EMERALD, + }, + { + keywords: ["service", "system", "network", "daemon", "module"], + style: BLUE, + }, + { + keywords: ["package", "program", "install", "app", "plugin", "tool"], + style: AMBER, + }, + { + keywords: ["theme", "visual", "color", "style", "font", "ui", "appearance"], + style: VIOLET, + }, ]; export function getCategoryStyle(title: string): CategoryStyle { @@ -96,7 +142,7 @@ export function getCategoryStyle(title: string): CategoryStyle { export type ColorMap = Map; -import type { SemanticChangeMap } from "@/types/shared"; +import type { Change, ChangeType, SemanticChangeMap } from "@/types/shared"; export function buildColorMap(changeMap: SemanticChangeMap): ColorMap { const map: ColorMap = new Map(); @@ -105,7 +151,10 @@ export function buildColorMap(changeMap: SemanticChangeMap): ColorMap { const assign = (key: string, title: string, forceColor: boolean) => { const lower = title.toLowerCase(); - const preferred = KEYWORD_STYLES.find(({ keywords }) => keywords.some((k) => lower.includes(k)))?.style ?? null; + const preferred = + KEYWORD_STYLES.find(({ keywords }) => + keywords.some((k) => lower.includes(k)), + )?.style ?? null; if (preferred && !used.has(preferred)) { map.set(key, preferred); @@ -121,7 +170,8 @@ export function buildColorMap(changeMap: SemanticChangeMap): ColorMap { } }; - for (const g of changeMap.groups) assign(String(g.summary.id), g.summary.title, true); + for (const g of changeMap.groups) + assign(String(g.summary.id), g.summary.title, true); for (const s of changeMap.singles) assign(s.hash, s.title, false); return map; @@ -134,3 +184,84 @@ export function formatRelativeTime(unixSeconds: number): string { if (diff < 86400) return `${Math.floor(diff / 3600)} hr ago`; return `${Math.floor(diff / 86400)}d ago`; } + +// ============================================================================= +// CHANGE CATEGORY COLORS +// ============================================================================= + +export type ChangeTypeStyle = { + icon: LucideIcon; + bg: string; + iconColor: string; +}; + +export const CHANGE_TYPE_STYLES: Record = { + new: { icon: FilePlus, bg: "bg-emerald-300/[0.07]", iconColor: "text-emerald-400" }, + edited: { icon: FilePen, bg: "bg-white/[0.06]", iconColor: "text-neutral-400" }, + removed: { icon: FileX, bg: "bg-red-500/[0.07]", iconColor: "text-red-400" }, + renamed: { icon: FileCode, bg: "bg-white/[0.06]", iconColor: "text-neutral-400" }, +}; + +export type ChangeWithRichType = Change & { + changeType: ChangeType; + oldFilename?: string; + shortFilename?: string; +}; + +export function inferChangeType(diff: string): ChangeType { + if (/^new file mode/m.test(diff)) return "new"; + if (/^deleted file mode/m.test(diff)) return "removed"; + return getChangeTypeFromChunks(diff) as ChangeType; +} + +export type RenamePair = { + oldChange: ChangeWithRichType; + newChange: ChangeWithRichType; +}; + +export function findRenamePairs(changes: ChangeWithRichType[]): RenamePair[] { + const pairs: RenamePair[] = []; + const newFiles = changes.filter((c) => c.changeType === "new"); + for (const newFile of newFiles) { + const removedFiles = changes.filter( + (c) => + c.shortFilename === newFile.shortFilename && c.changeType === "removed", + ); + if (removedFiles.length === 1) { + pairs.push({ oldChange: removedFiles[0], newChange: newFile }); + } + } + return pairs; +} + +export function categorizeRenamed( + changes: ChangeWithRichType[], +): ChangeWithRichType[] { + const pairs = findRenamePairs(changes); + const consumedRemovals = new Set(); + const renamedChanges: ChangeWithRichType[] = []; + + for (const { oldChange, newChange } of pairs) { + newChange.oldFilename = oldChange.filename; + newChange.changeType = "renamed"; + consumedRemovals.add(oldChange.diff); + renamedChanges.push(newChange); + } + + const remainingChanges = changes.filter( + (c) => !renamedChanges.includes(c) && !consumedRemovals.has(c.diff), + ); + return [...remainingChanges, ...renamedChanges]; +} + +export function enrichChanges(changes: Change[]): ChangeWithRichType[] { + return changes + .map((c) => ({ + ...c, + changeType: inferChangeType(c.diff), + })) + .map((c) => ({ + ...c, + shortFilename: getShortFilename(c.filename), + })); +} diff --git a/apps/native/src/hooks/use-prefs.ts b/apps/native/src/hooks/use-prefs.ts index 59be0e48..7001f2a5 100644 --- a/apps/native/src/hooks/use-prefs.ts +++ b/apps/native/src/hooks/use-prefs.ts @@ -1,4 +1,4 @@ -import { useWidgetStore, type ConfirmPrefKey } from "@/stores/widget-store"; +import { useWidgetStore, type BoolPrefKey, type ConfirmPrefKey } from "@/stores/widget-store"; import { darwinAPI } from "@/tauri-api"; export function usePrefs() { @@ -6,18 +6,21 @@ export function usePrefs() { const prefs = await darwinAPI.ui.getPrefs(); if (prefs) { useWidgetStore.getState().initConfirmPrefs(prefs); + useWidgetStore.getState().setAutoSummarizeOnFocus(prefs.autoSummarizeOnFocus ?? false); } }; - const setPref = async (key: ConfirmPrefKey, value: boolean) => { + const setPref = async (key: BoolPrefKey, value: boolean) => { const previous = useWidgetStore.getState()[key]; - useWidgetStore.getState().setConfirmPref(key, value); + useWidgetStore.getState().setBoolPref(key, value); try { await darwinAPI.ui.setPrefs({ [key]: value }); } catch { - useWidgetStore.getState().setConfirmPref(key, previous); + useWidgetStore.getState().setBoolPref(key, previous); } }; return { loadPrefs, setPref }; } + +export type { ConfirmPrefKey, BoolPrefKey }; diff --git a/apps/native/src/hooks/use-summary.ts b/apps/native/src/hooks/use-summary.ts index b7894b95..5e6ab99a 100644 --- a/apps/native/src/hooks/use-summary.ts +++ b/apps/native/src/hooks/use-summary.ts @@ -6,6 +6,8 @@ import { useCallback } from "react"; * Hook for fetching and managing the AI-generated summary of changes. */ export function useSummary() { + const autoSummarizeOnFocus = useWidgetStore((s) => s.autoSummarizeOnFocus); + const findChangeMap = useCallback(async (): Promise => { const { setChangeMap, setSummaryAvailable } = useWidgetStore.getState(); try { @@ -31,8 +33,20 @@ export function useSummary() { }, []); const generateCurrentSummary = useCallback(async () => { - await darwinAPI.summarizedChanges.summarizeCurrent(); + const { setSummarizing, setChangeMap, setSummaryAvailable } = useWidgetStore.getState(); + setSummarizing(true); + try { + const map = await darwinAPI.summarizedChanges.summarizeCurrent(); + setChangeMap(map); + setSummaryAvailable(map.groups.length > 0 || map.singles.length > 0); + } finally { + setSummarizing(false); + } }, []); - return { findChangeMap, generateCommitMessage, generateCurrentSummary }; + const summarizeOnFocus = useCallback(() => { + if (autoSummarizeOnFocus) generateCurrentSummary(); + }, [autoSummarizeOnFocus, generateCurrentSummary]); + + return { findChangeMap, generateCommitMessage, generateCurrentSummary, summarizeOnFocus }; } diff --git a/apps/native/src/stores/widget-store.test.ts b/apps/native/src/stores/widget-store.test.ts index c2e1000a..f2328945 100644 --- a/apps/native/src/stores/widget-store.test.ts +++ b/apps/native/src/stores/widget-store.test.ts @@ -135,9 +135,9 @@ describe("setProcessing", () => { // --------------------------------------------------------------------------- describe("confirmation preferences", () => { - it("setConfirmPref updates only the targeted key", () => { + it("setBoolPref updates only the targeted key", () => { const store = createWidgetStore(); - store.getState().setConfirmPref("confirmBuild", false); + store.getState().setBoolPref("confirmBuild", false); const s = store.getState(); expect(s.confirmBuild).toBe(false); expect(s.confirmClear).toBe(true); diff --git a/apps/native/src/stores/widget-store.ts b/apps/native/src/stores/widget-store.ts index 691525c6..c37de5bb 100644 --- a/apps/native/src/stores/widget-store.ts +++ b/apps/native/src/stores/widget-store.ts @@ -29,6 +29,7 @@ export type SettingsTab = "general" | "api-keys" | "ai-models" | "preferences"; export type WidgetStep = "permissions" | "nix-setup" | "setup" | "begin" | "evolve" | "commit" | "manualEvolve" | "manualCommit" | "history"; export type ProcessingAction = "evolve" | "apply" | "merge" | "cancel" | null; export type ConfirmPrefKey = "confirmBuild" | "confirmClear" | "confirmRollback"; +export type BoolPrefKey = ConfirmPrefKey | "autoSummarizeOnFocus"; // Rebuild state for showing progress inline in the widget export type RebuildErrorType = @@ -114,6 +115,7 @@ export interface WidgetState { // UI summaryAvailable: boolean; + isSummarizing: boolean; isGenerating: boolean; settingsOpen: boolean; settingsActiveTab: SettingsTab | null; @@ -136,6 +138,9 @@ export interface WidgetState { confirmClear: boolean; confirmRollback: boolean; + // Summarization preferences + autoSummarizeOnFocus: boolean; + // Editor editingFile: string | null; } @@ -181,11 +186,15 @@ export interface WidgetActions { addAnalyzingHistoryHash: (hash: string) => void; removeAnalyzingHistoryHash: (hash: string) => void; - // Confirmation preferences - setConfirmPref: (key: ConfirmPrefKey, value: boolean) => void; + // Boolean preferences + setBoolPref: (key: BoolPrefKey, value: boolean) => void; initConfirmPrefs: (prefs: Partial>) => void; + // Summarization preferences + setAutoSummarizeOnFocus: (value: boolean) => void; + // Client-side state (NOT from server) + setSummarizing: (summarizing: boolean) => void; setGenerating: (generating: boolean) => void; clearPreview: () => void; setFeedbackTypeOverride: (type: FeedbackType | null) => void; @@ -284,6 +293,7 @@ export const initialWidgetState: WidgetState = { // UI isBootstrapping: false, + isSummarizing: false, isGenerating: false, settingsOpen: false, settingsActiveTab: null, @@ -300,6 +310,9 @@ export const initialWidgetState: WidgetState = { confirmClear: true, confirmRollback: true, + // Summarization preferences + autoSummarizeOnFocus: false, + // Editor editingFile: null, }; @@ -338,13 +351,14 @@ export function createWidgetStore(initialState?: Partial) { }), setChangeMap: (changeMap) => set({ changeMap }), setSummaryAvailable: (summaryAvailable) => set({ summaryAvailable }), - setConfirmPref: (key, value) => set({ [key]: value }), + setBoolPref: (key: BoolPrefKey, value: boolean) => set({ [key]: value }), initConfirmPrefs: (prefs) => set({ confirmBuild: prefs.confirmBuild ?? true, confirmClear: prefs.confirmClear ?? true, confirmRollback: prefs.confirmRollback ?? true, }), + setAutoSummarizeOnFocus: (value) => set({ autoSummarizeOnFocus: value }), setHistory: (history) => set({ history }), setHistoryLoading: (historyLoading) => set({ historyLoading }), addAnalyzingHistoryHash: (hash) => @@ -381,6 +395,7 @@ export function createWidgetStore(initialState?: Partial) { setNixDownloadProgress: (nixDownloadProgress) => set({ nixDownloadProgress }), setDarwinRebuildAvailable: (darwinRebuildAvailable) => set({ darwinRebuildAvailable }), setDarwinRebuildPrefetching: (darwinRebuildPrefetching) => set({ darwinRebuildPrefetching }), + setSummarizing: (isSummarizing) => set({ isSummarizing }), setGenerating: (isGenerating) => set({ isGenerating }), clearPreview: () => set({ diff --git a/apps/native/src/tauri-api.ts b/apps/native/src/tauri-api.ts index cb8d4b0e..b0626a5a 100644 --- a/apps/native/src/tauri-api.ts +++ b/apps/native/src/tauri-api.ts @@ -12,6 +12,7 @@ import type { RollbackResult, SemanticChangeMap, SetDirResult, + UiPrefs as DarwinPrefs, } from "./types/shared"; export type { @@ -28,6 +29,7 @@ export type { SemanticChangeMap, SetDirResult, SummarizedChangeSet, + UiPrefs as DarwinPrefs, WatcherEvent, } from "./types/shared"; export type { Change, Commit } from "./types/sqlite"; @@ -41,23 +43,6 @@ export interface DarwinConfig { hostAttr?: string | null; } -export interface DarwinPrefs { - openrouterApiKey?: string; - openaiApiKey?: string; - summaryProvider?: string; - summaryModel?: string; - evolveProvider?: string; - evolveModel?: string; - maxIterations?: number; - maxBuildAttempts?: number; - ollamaApiBaseUrl?: string; - vllmApiBaseUrl?: string; - vllmApiKey?: string; - sendDiagnostics?: boolean; - confirmBuild?: boolean; - confirmClear?: boolean; - confirmRollback?: boolean; -} export const DEFAULT_MAX_ITERATIONS = 25; @@ -292,7 +277,7 @@ export const darwinAPI = { }, summarizedChanges: { findChangeMap: () => invoke("find_change_map"), - summarizeCurrent: () => invoke("summarize_current"), + summarizeCurrent: () => invoke("summarize_current"), generateCommitMessage: () => invoke("generate_commit_message"), }, feedback: { @@ -302,7 +287,7 @@ export const darwinAPI = { }, ui: { getPrefs: () => invoke("ui_get_prefs"), - setPrefs: (prefs: DarwinPrefs) => invoke("ui_set_prefs", { prefs }), + setPrefs: (prefs: Partial) => invoke("ui_set_prefs", { prefs }), }, models: { getCached: (provider: string) => invoke("get_cached_models", { provider }), diff --git a/apps/native/src/types/shared.ts b/apps/native/src/types/shared.ts index a48d9ac4..5d45efb5 100644 --- a/apps/native/src/types/shared.ts +++ b/apps/native/src/types/shared.ts @@ -127,6 +127,11 @@ export type SummarizedChange = { change: Change; ownSummary: ChangeSummary | nul export type SummarizedChangeSet = { changeSet: ChangeSet; changes: SummarizedChange[]; missedHashes: string[] } +/** + * User interface preferences (synced to settings.json via tauri-plugin-store). + */ +export type UiPrefs = { openrouterApiKey: string | null; openaiApiKey: string | null; ollamaApiBaseUrl: string | null; vllmApiBaseUrl: string | null; vllmApiKey: string | null; summaryProvider: string | null; summaryModel: string | null; evolveProvider: string | null; evolveModel: string | null; maxIterations: number | null; maxBuildAttempts: number | null; sendDiagnostics: boolean; confirmBuild: boolean; confirmClear: boolean; confirmRollback: boolean; autoSummarizeOnFocus: boolean } + /** * Event payload emitted by the git status watcher. */