From d14c3bd4e06aa69c7b26e992bd7f238a726cd72c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 00:35:35 +0000 Subject: [PATCH 1/6] Add filter_elements: unit-aware element filter with predicates + aggregation Our answer to revit-mcp's ai_element_filter, but stronger: a structured query the model can trust without knowing Revit's internal feet or exact BuiltInParameter names. - predicates [{parameter, op, value}] combined by all/any; ops eq/ne/gt/lt/gte/lte, contains/starts_with, exists/not_exists. - unit-aware pseudo-parameters computed from geometry: length(mm), height(mm), area(m2), volume(m3), elevation(mm), plus name/type/family/level/category and any parameter by fuzzy display name. - scope by on_level and/or in_active_view. - optional aggregate {op: count|sum|avg|min|max, parameter} rolls up the matched set in the same call (e.g. total wall length in one round-trip). Registered as a core tool. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01RCGB6xWx3ZtmWZRQEJtN6B --- ClaudeRevit/App.cs | 1 + ClaudeRevit/Services/ToolSearchLogic.cs | 2 +- ClaudeRevit/Tools/FilterElements.cs | 348 ++++++++++++++++++++++++ 3 files changed, 350 insertions(+), 1 deletion(-) create mode 100644 ClaudeRevit/Tools/FilterElements.cs diff --git a/ClaudeRevit/App.cs b/ClaudeRevit/App.cs index c7ae149..dac0ce9 100644 --- a/ClaudeRevit/App.cs +++ b/ClaudeRevit/App.cs @@ -21,6 +21,7 @@ public Result OnStartup(UIControlledApplication application) ToolRegistry.Instance.Register(new GetLevels()); ToolRegistry.Instance.Register(new GetSelection()); ToolRegistry.Instance.Register(new QueryElements()); + ToolRegistry.Instance.Register(new FilterElements()); ToolRegistry.Instance.Register(new AnalyzeWarnings()); ToolRegistry.Instance.Register(new CreateWall()); ToolRegistry.Instance.Register(new CreateWallType()); diff --git a/ClaudeRevit/Services/ToolSearchLogic.cs b/ClaudeRevit/Services/ToolSearchLogic.cs index c580583..337820b 100644 --- a/ClaudeRevit/Services/ToolSearchLogic.cs +++ b/ClaudeRevit/Services/ToolSearchLogic.cs @@ -20,7 +20,7 @@ public sealed record SearchResult(List Categories, string Message); public static readonly HashSet CoreToolNames = new(StringComparer.Ordinal) { // Query / inspection - "get_selection", "query_elements", "get_element_parameters", "get_type_parameters", + "get_selection", "query_elements", "filter_elements", "get_element_parameters", "get_type_parameters", "get_element_locations", "get_element_bounding_box", "get_levels", "get_model_statistics", "get_project_catalog", "get_project_info", "get_active_view_info", "list_family_types", "list_loaded_families", "list_materials", "measure_distance", "analyze_warnings", diff --git a/ClaudeRevit/Tools/FilterElements.cs b/ClaudeRevit/Tools/FilterElements.cs new file mode 100644 index 0000000..16ddb58 --- /dev/null +++ b/ClaudeRevit/Tools/FilterElements.cs @@ -0,0 +1,348 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text.Json; +using Anthropic.Models.Beta.Messages; +using Autodesk.Revit.DB; +using Autodesk.Revit.UI; + +namespace ClaudeRevit.Tools; + +// Smart element filter — the answer to "find all walls taller than 3 m on Level 2, and total their +// length". A structured, UNIT-AWARE query so the model never has to know Revit's internal feet or the +// exact BuiltInParameter enum. Beats a vague natural-language filter: predicates combine with AND/OR, +// pseudo-parameters (length/area/volume/height/elevation) are computed from geometry regardless of how +// they're stored, scoping by level / active view is built in, and an optional aggregate rolls the +// matched set up (count/sum/avg/min/max) in the SAME call. +public class FilterElements : IRevitTool +{ + public string Name => "filter_elements"; + + public string Description => + "Filter elements of a category by parameter conditions and return the matches (id, name, type, " + + "level, plus each matched value). Optionally aggregate the matched set in one call.\n" + + "predicates: array of {parameter, op, value}, combined by `match` ('all'=AND default, 'any'=OR).\n" + + "ops: eq, ne, gt, lt, gte, lte (numeric), contains, starts_with (text), exists, not_exists.\n" + + "Pseudo-parameters (unit-aware, computed from geometry — USE THESE for numeric compares): " + + "'length' (mm), 'height' (mm, bbox Z-extent), 'area' (m2), 'volume' (m3), 'elevation' (mm). " + + "Also 'name', 'type'/'type_name', 'family', 'level', 'category', or ANY parameter by display " + + "name (fuzzy, case-insensitive) for text ops. Numeric gt/lt on a normal shared/instance " + + "parameter works only if it stores an integer; for others use text ops.\n" + + "Scope with `on_level` (level name) and/or `in_active_view`. `aggregate`: {op, parameter} where " + + "op is count|sum|avg|min|max (parameter omitted for count)."; + + public InputSchema InputSchema => new() + { + Properties = new Dictionary + { + ["category"] = JsonSerializer.SerializeToElement(new + { + type = "string", + description = "Revit category, e.g. 'Walls', 'Structural Columns', 'Doors', 'Rooms'." + }), + ["predicates"] = JsonSerializer.SerializeToElement(new + { + type = "array", + description = "Conditions, e.g. [{\"parameter\":\"length\",\"op\":\"gt\",\"value\":3000}].", + items = new + { + type = "object", + properties = new + { + parameter = new { type = "string" }, + op = new { type = "string", @enum = new[] { "eq", "ne", "gt", "lt", "gte", "lte", "contains", "starts_with", "exists", "not_exists" } }, + value = new { description = "String or number (omit for exists/not_exists)." } + }, + required = new[] { "parameter", "op" } + } + }), + ["match"] = JsonSerializer.SerializeToElement(new + { + type = "string", + @enum = new[] { "all", "any" }, + description = "Combine predicates with AND ('all', default) or OR ('any')." + }), + ["on_level"] = JsonSerializer.SerializeToElement(new + { + type = "string", + description = "Only elements hosted on this level (name)." + }), + ["in_active_view"] = JsonSerializer.SerializeToElement(new + { + type = "boolean", + description = "Only elements shown in the active view." + }), + ["aggregate"] = JsonSerializer.SerializeToElement(new + { + type = "object", + description = "Roll up the matched set, e.g. {\"op\":\"sum\",\"parameter\":\"length\"}.", + properties = new + { + op = new { type = "string", @enum = new[] { "count", "sum", "avg", "min", "max" } }, + parameter = new { type = "string" } + }, + required = new[] { "op" } + }), + ["limit"] = JsonSerializer.SerializeToElement(new + { + type = "integer", + description = "Max elements listed (default 50, max 500). Aggregates use the full set.", + minimum = 1, + maximum = 500 + }) + }, + Required = ["category"] + }; + + public bool RequiresTransaction => false; + + private const double FtToMm = 304.8; + private const double Ft2ToM2 = 0.09290304; + private const double Ft3ToM3 = 0.028316846592; + + public string Execute(IReadOnlyDictionary input, UIApplication app) + { + var doc = app.ActiveUIDocument?.Document + ?? throw new InvalidOperationException("No document is open."); + + var category = input["category"].GetString() + ?? throw new InvalidOperationException("category is required."); + var bic = CategoryResolve.Parse(category); + + var matchAny = input.TryGetValue("match", out var mm) && + string.Equals(mm.GetString(), "any", StringComparison.OrdinalIgnoreCase); + var limit = input.TryGetValue("limit", out var l) ? l.GetInt32() : 50; + if (limit < 1 || limit > 500) limit = 50; + + var predicates = ParsePredicates(input); + + // Scope: active view (much cheaper) or the whole document, then the category. + FilteredElementCollector collector; + if (input.TryGetValue("in_active_view", out var iav) && iav.ValueKind == JsonValueKind.True && + doc.ActiveView != null) + collector = new FilteredElementCollector(doc, doc.ActiveView.Id); + else + collector = new FilteredElementCollector(doc); + + var candidates = collector.OfCategory(bic).WhereElementIsNotElementType(); + + ElementId? levelFilter = null; + if (input.TryGetValue("on_level", out var lv) && lv.ValueKind == JsonValueKind.String) + { + var name = lv.GetString(); + levelFilter = new FilteredElementCollector(doc).OfClass(typeof(Level)).Cast() + .FirstOrDefault(x => string.Equals(x.Name, name, StringComparison.OrdinalIgnoreCase))?.Id; + if (levelFilter == null) + return JsonSerializer.Serialize(new { error = $"Level '{name}' not found." }); + } + + var matched = new List(); + foreach (var el in candidates) + { + try + { + if (levelFilter != null && el.LevelId != levelFilter) continue; + if (Matches(el, doc, predicates, matchAny)) matched.Add(el); + } + catch { /* skip elements that throw on a parameter read */ } + } + + object? aggregate = null; + if (input.TryGetValue("aggregate", out var agg) && agg.ValueKind == JsonValueKind.Object) + aggregate = Aggregate(matched, doc, agg); + + var listed = matched.Take(limit).Select(e => new + { + id = e.Id.Value, + name = e.Name, + type_name = doc.GetElement(e.GetTypeId())?.Name, + level = e.LevelId != ElementId.InvalidElementId ? doc.GetElement(e.LevelId)?.Name : null + }).ToList(); + + return JsonSerializer.Serialize(new + { + category, + match = matchAny ? "any" : "all", + total_matched = matched.Count, + listed = listed.Count, + truncated = matched.Count > listed.Count, + aggregate, + elements = listed + }); + } + + private sealed record Pred(string Param, string Op, JsonElement? Value); + + private static List ParsePredicates(IReadOnlyDictionary input) + { + var list = new List(); + if (!input.TryGetValue("predicates", out var arr) || arr.ValueKind != JsonValueKind.Array) + return list; + foreach (var p in arr.EnumerateArray()) + { + var param = p.TryGetProperty("parameter", out var pr) ? pr.GetString() : null; + var op = p.TryGetProperty("op", out var o) ? o.GetString() : null; + if (string.IsNullOrWhiteSpace(param) || string.IsNullOrWhiteSpace(op)) continue; + JsonElement? val = p.TryGetProperty("value", out var v) ? v : (JsonElement?)null; + list.Add(new Pred(param!.Trim(), op!.Trim().ToLowerInvariant(), val)); + } + return list; + } + + private static bool Matches(Element el, Document doc, List preds, bool any) + { + if (preds.Count == 0) return true; + foreach (var p in preds) + { + var ok = Test(el, doc, p); + if (any && ok) return true; + if (!any && !ok) return false; + } + return !any; + } + + private static bool Test(Element el, Document doc, Pred p) + { + var (num, text) = Resolve(el, doc, p.Param); + var present = num.HasValue || !string.IsNullOrEmpty(text); + switch (p.Op) + { + case "exists": return present; + case "not_exists": return !present; + } + if (p.Value is not { } val) return false; + + // Numeric comparison when both the element value and the predicate value are numbers. + if (num.HasValue && TryNumber(val, out var target)) + { + return p.Op switch + { + "eq" => Math.Abs(num.Value - target) < 1e-6, + "ne" => Math.Abs(num.Value - target) >= 1e-6, + "gt" => num.Value > target, + "lt" => num.Value < target, + "gte" => num.Value >= target, + "lte" => num.Value <= target, + _ => false + }; + } + + // Text comparison otherwise. + var left = text ?? (num?.ToString(CultureInfo.InvariantCulture) ?? ""); + var right = val.ValueKind == JsonValueKind.String + ? val.GetString() ?? "" + : val.ToString(); + return p.Op switch + { + "eq" => string.Equals(left, right, StringComparison.OrdinalIgnoreCase), + "ne" => !string.Equals(left, right, StringComparison.OrdinalIgnoreCase), + "contains" => left.IndexOf(right, StringComparison.OrdinalIgnoreCase) >= 0, + "starts_with" => left.StartsWith(right, StringComparison.OrdinalIgnoreCase), + _ => false + }; + } + + // Returns (numeric-in-friendly-unit, text) for a pseudo- or named parameter. Numeric is only set + // for well-defined units (mm/m2/m3) and integer parameters, so numeric compares are never + // ambiguous about feet vs metres. + private static (double? num, string? text) Resolve(Element el, Document doc, string param) + { + switch (param.ToLowerInvariant()) + { + case "length": + return (el.Location is LocationCurve lc ? lc.Curve.Length * FtToMm : (double?)null, null); + case "height": + var bb = el.get_BoundingBox(null); + return (bb != null ? (bb.Max.Z - bb.Min.Z) * FtToMm : (double?)null, null); + case "area": + return (Ft2(el, BuiltInParameter.HOST_AREA_COMPUTED, BuiltInParameter.ROOM_AREA), null); + case "volume": + return (Ft3(el, BuiltInParameter.HOST_VOLUME_COMPUTED, BuiltInParameter.ROOM_VOLUME), null); + case "elevation": + if (el is Level lvl) return (lvl.Elevation * FtToMm, null); + var bbx = el.get_BoundingBox(null); + return (bbx != null ? bbx.Min.Z * FtToMm : (double?)null, null); + case "name": + return (null, el.Name); + case "category": + return (null, el.Category?.Name); + case "level": + return (null, el.LevelId != ElementId.InvalidElementId ? doc.GetElement(el.LevelId)?.Name : null); + case "type": + case "type_name": + return (null, doc.GetElement(el.GetTypeId())?.Name); + case "family": + return (null, (doc.GetElement(el.GetTypeId()) as ElementType)?.FamilyName); + } + + // Any parameter by (fuzzy) display name — exact first, then case-insensitive contains. + var pm = el.LookupParameter(param) ?? FuzzyParam(el, param); + if (pm == null) return (null, null); + return pm.StorageType switch + { + StorageType.Integer => (pm.AsInteger(), pm.AsInteger().ToString(CultureInfo.InvariantCulture)), + StorageType.String => (null, pm.AsString()), + StorageType.Double => (null, pm.AsValueString()), + StorageType.ElementId => (null, pm.AsValueString() ?? pm.AsElementId().Value.ToString()), + _ => (null, pm.AsValueString()) + }; + } + + private static Parameter? FuzzyParam(Element el, string name) => + el.Parameters.Cast().FirstOrDefault(p => + (p.Definition?.Name ?? "").IndexOf(name, StringComparison.OrdinalIgnoreCase) >= 0); + + private static double? Ft2(Element el, params BuiltInParameter[] bips) + { + foreach (var bip in bips) + { + var p = el.get_Parameter(bip); + if (p != null && p.HasValue && p.StorageType == StorageType.Double) return p.AsDouble() * Ft2ToM2; + } + return null; + } + + private static double? Ft3(Element el, params BuiltInParameter[] bips) + { + foreach (var bip in bips) + { + var p = el.get_Parameter(bip); + if (p != null && p.HasValue && p.StorageType == StorageType.Double) return p.AsDouble() * Ft3ToM3; + } + return null; + } + + private static bool TryNumber(JsonElement v, out double d) + { + if (v.ValueKind == JsonValueKind.Number) { d = v.GetDouble(); return true; } + if (v.ValueKind == JsonValueKind.String && + double.TryParse(v.GetString(), NumberStyles.Any, CultureInfo.InvariantCulture, out d)) return true; + d = 0; return false; + } + + private static object Aggregate(List matched, Document doc, JsonElement agg) + { + var op = (agg.TryGetProperty("op", out var o) ? o.GetString() : "count")?.ToLowerInvariant() ?? "count"; + if (op == "count") return new { op, value = matched.Count }; + + var param = agg.TryGetProperty("parameter", out var pr) ? pr.GetString() : null; + if (string.IsNullOrWhiteSpace(param)) + return new { op, error = "parameter is required for sum/avg/min/max." }; + + var vals = matched + .Select(e => { try { return Resolve(e, doc, param!).num; } catch { return null; } }) + .Where(x => x.HasValue).Select(x => x!.Value).ToList(); + if (vals.Count == 0) return new { op, parameter = param, value = (double?)null, note = "no numeric values" }; + + double result = op switch + { + "sum" => vals.Sum(), + "avg" => vals.Average(), + "min" => vals.Min(), + "max" => vals.Max(), + _ => double.NaN + }; + return new { op, parameter = param, value = Math.Round(result, 3), sampled = vals.Count }; + } +} From a1f0b7fde15310ff6fa965462a9e20de77d15255 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 01:01:39 +0000 Subject: [PATCH 2/6] Version info + update check (notify-only) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Stamp the numeric version onto the assembly at release build time (-p:Version). - UpdateChecker: reports the running version and queries the latest GitHub release; compares numerically; surfaces the installer URL. Best-effort/non-fatal. - Chat pane footer shows a clickable '⬆ vX.Y available' when a newer release exists (opens the installer); loaded DLLs can't self-replace while Revit runs, so it's notify-only, user installs on their terms. - Help window title shows the current version. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01RCGB6xWx3ZtmWZRQEJtN6B --- .github/workflows/release.yml | 5 +- ClaudeRevit/Services/UpdateChecker.cs | 110 ++++++++++++++++++++++++++ ClaudeRevit/UI/ChatPaneView.xaml | 9 ++- ClaudeRevit/UI/ChatPaneView.xaml.cs | 29 +++++++ ClaudeRevit/UI/HelpWindow.xaml.cs | 2 + 5 files changed, 152 insertions(+), 3 deletions(-) create mode 100644 ClaudeRevit/Services/UpdateChecker.cs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e428199..f68e151 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -50,7 +50,10 @@ jobs: Write-Host "=== Building for Revit $rv ===" # Clean between versions: 2025/2026 (net8) and 2027 (net10) share bin/obj. Remove-Item ClaudeRevit\bin, ClaudeRevit\obj -Recurse -Force -ErrorAction SilentlyContinue - dotnet build ClaudeRevit\ClaudeRevit.csproj -c Release -p:RevitVersion=$rv -p:SkipDeploy=true + # Stamp the numeric version onto the assembly so the running add-in can report it and + # compare against the latest GitHub release (the update check). "v1.97" -> "1.97". + $numver = $ver.TrimStart('v') + dotnet build ClaudeRevit\ClaudeRevit.csproj -c Release -p:RevitVersion=$rv -p:SkipDeploy=true -p:Version=$numver if ($LASTEXITCODE -ne 0) { throw "Build failed for Revit $rv" } $src = "ClaudeRevit\bin\Release\release" if (-not (Test-Path $src)) { throw "Staging folder not found for Revit $rv at $src" } diff --git a/ClaudeRevit/Services/UpdateChecker.cs b/ClaudeRevit/Services/UpdateChecker.cs new file mode 100644 index 0000000..49f3d15 --- /dev/null +++ b/ClaudeRevit/Services/UpdateChecker.cs @@ -0,0 +1,110 @@ +using System; +using System.Net.Http; +using System.Reflection; +using System.Text.Json; +using System.Threading.Tasks; + +namespace ClaudeRevit.Services; + +// Reports the running add-in version and checks GitHub for a newer release. Loaded DLLs can't +// self-replace while Revit is open, so this never installs anything — it surfaces "an update is +// available" plus the installer URL, and the user updates on their own terms. All network work is +// best-effort and non-fatal: a failed check just leaves LatestVersion null. +public static class UpdateChecker +{ + private const string Owner = "debug23win"; + private const string Repo = "clauderevit"; + private static readonly HttpClient Http = new() { Timeout = TimeSpan.FromSeconds(10) }; + + // "v1.97" — from the assembly version stamped at release time; "dev" for a local build (0.0.0). + public static string CurrentVersion + { + get + { + try + { + var v = Assembly.GetExecutingAssembly().GetName().Version; + if (v == null || (v.Major == 0 && v.Minor == 0)) return "dev"; + return v.Build > 0 ? $"v{v.Major}.{v.Minor}.{v.Build}" : $"v{v.Major}.{v.Minor}"; + } + catch { return "dev"; } + } + } + + public sealed class Result + { + public string Current = ""; + public string? Latest; // "v1.98" + public bool UpdateAvailable; + public string? DownloadUrl; // the installer .exe asset, or the release page + public string? Error; + } + + // Query the latest release. Cached for the process lifetime after the first success so the pane + // can call it freely. + private static Result? _cached; + + public static async Task CheckAsync() + { + if (_cached is { UpdateAvailable: true }) return _cached; + + var result = new Result { Current = CurrentVersion }; + if (result.Current == "dev") { result.Error = "local build"; return result; } + + try + { + using var req = new HttpRequestMessage(HttpMethod.Get, + $"https://api.github.com/repos/{Owner}/{Repo}/releases/latest"); + req.Headers.UserAgent.ParseAdd("ClaudeRevit-UpdateChecker"); + req.Headers.Accept.ParseAdd("application/vnd.github+json"); + + using var resp = await Http.SendAsync(req); + if (!resp.IsSuccessStatusCode) { result.Error = $"HTTP {(int)resp.StatusCode}"; return result; } + + using var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()); + var root = doc.RootElement; + var tag = root.TryGetProperty("tag_name", out var t) ? t.GetString() : null; + if (string.IsNullOrWhiteSpace(tag)) { result.Error = "no tag"; return result; } + + result.Latest = tag; + result.DownloadUrl = root.TryGetProperty("html_url", out var h) ? h.GetString() : null; + if (root.TryGetProperty("assets", out var assets) && assets.ValueKind == JsonValueKind.Array) + foreach (var a in assets.EnumerateArray()) + { + var name = a.TryGetProperty("name", out var n) ? n.GetString() ?? "" : ""; + if (name.EndsWith(".exe", StringComparison.OrdinalIgnoreCase) && + a.TryGetProperty("browser_download_url", out var u)) + { result.DownloadUrl = u.GetString(); break; } + } + + result.UpdateAvailable = IsNewer(tag!, result.Current); + if (result.UpdateAvailable) _cached = result; + } + catch (Exception ex) { result.Error = ex.Message; } + return result; + } + + // Compare "v1.98" vs "v1.97" numerically, component by component. Unparseable → not newer. + private static bool IsNewer(string latest, string current) + { + var a = Parse(latest); + var b = Parse(current); + for (int i = 0; i < Math.Max(a.Length, b.Length); i++) + { + var x = i < a.Length ? a[i] : 0; + var y = i < b.Length ? b[i] : 0; + if (x != y) return x > y; + } + return false; + } + + private static int[] Parse(string v) + { + v = v.TrimStart('v', 'V'); + var parts = v.Split('.'); + var nums = new int[parts.Length]; + for (int i = 0; i < parts.Length; i++) + nums[i] = int.TryParse(parts[i], out var n) ? n : 0; + return nums; + } +} diff --git a/ClaudeRevit/UI/ChatPaneView.xaml b/ClaudeRevit/UI/ChatPaneView.xaml index cdb25b7..7624d72 100644 --- a/ClaudeRevit/UI/ChatPaneView.xaml +++ b/ClaudeRevit/UI/ChatPaneView.xaml @@ -196,8 +196,13 @@ - + + + + diff --git a/ClaudeRevit/UI/ChatPaneView.xaml.cs b/ClaudeRevit/UI/ChatPaneView.xaml.cs index 4a34f5d..0b10d24 100644 --- a/ClaudeRevit/UI/ChatPaneView.xaml.cs +++ b/ClaudeRevit/UI/ChatPaneView.xaml.cs @@ -51,6 +51,35 @@ public ChatPaneView() // code, mark it handled so the chat pane can never crash Revit. Exceptions from // Revit itself or other add-ins are logged but left to their normal handling. Dispatcher.UnhandledException += OnDispatcherUnhandledException; + + _ = CheckForUpdateAsync(); + } + + private string? _updateUrl; + + // Best-effort, non-blocking: if GitHub has a newer release, reveal the footer link. Loaded DLLs + // can't self-replace while Revit is open, so we only point the user at the installer. + private async Task CheckForUpdateAsync() + { + try + { + var r = await UpdateChecker.CheckAsync(); + if (!r.UpdateAvailable || r.DownloadUrl == null) return; + _updateUrl = r.DownloadUrl; + await Dispatcher.InvokeAsync(() => + { + UpdateNotice.Text = $"⬆ {r.Latest} available"; + UpdateNotice.Visibility = Visibility.Visible; + }); + } + catch { /* never let an update check disturb the pane */ } + } + + private void UpdateNotice_Click(object sender, MouseButtonEventArgs e) + { + if (string.IsNullOrEmpty(_updateUrl)) return; + try { System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(_updateUrl) { UseShellExecute = true }); } + catch (Exception ex) { Log.Error("Opening update URL failed", ex); } } private void OnDispatcherUnhandledException(object sender, DispatcherUnhandledExceptionEventArgs e) diff --git a/ClaudeRevit/UI/HelpWindow.xaml.cs b/ClaudeRevit/UI/HelpWindow.xaml.cs index 7a67b92..b91729e 100644 --- a/ClaudeRevit/UI/HelpWindow.xaml.cs +++ b/ClaudeRevit/UI/HelpWindow.xaml.cs @@ -1,4 +1,5 @@ using System.Windows; +using ClaudeRevit.Services; namespace ClaudeRevit.UI; @@ -7,5 +8,6 @@ public partial class HelpWindow : Window public HelpWindow() { InitializeComponent(); + Title = $"Claude Revit {UpdateChecker.CurrentVersion} — Help / Помощь"; } } From c4cfa6b8f3106804bfd77cd63a43415dee5688a0 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 08:25:01 +0000 Subject: [PATCH 3/6] Rework Settings into tabs + About/updates tab Split the long single-scroll settings into tabs (same x:Names, so behaviour is unchanged): General (API key, code exec, confirmations, balance, max rounds), Models (Auto advisor/executor, diagnostics, alternative provider), Subscription (MCP) (server, port, config, Claude Code path), Tools (tool groups), and a new About tab showing the current version with a 'Check for updates' button. RU/EN localization for the tab headers and About section. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01RCGB6xWx3ZtmWZRQEJtN6B --- ClaudeRevit/UI/SettingsWindow.xaml | 370 ++++++++++++++------------ ClaudeRevit/UI/SettingsWindow.xaml.cs | 33 +++ 2 files changed, 238 insertions(+), 165 deletions(-) diff --git a/ClaudeRevit/UI/SettingsWindow.xaml b/ClaudeRevit/UI/SettingsWindow.xaml index cd459ac..7732b25 100644 --- a/ClaudeRevit/UI/SettingsWindow.xaml +++ b/ClaudeRevit/UI/SettingsWindow.xaml @@ -30,170 +30,210 @@ Click="SaveButton_Click" IsDefault="True" /> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Sonnet 5 (balanced) - Haiku 4.5 (cheapest) - - - - Opus 4.8 (default) - Fable 5 (hardest tasks) - - - - - - - - - - - - - - - - - - - - - - - - - - — not used — - Google Gemini (free tier) - ChatGPT / OpenAI (paid) - xAI Grok - DeepSeek (cheap) - Qwen (DashScope) - OpenRouter (has free models) - Groq (free tier) - Ollama (local, free) - LM Studio (local, free) - Custom (any OpenAI-compatible) - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Sonnet 5 (balanced) + Haiku 4.5 (cheapest) + + + + Opus 4.8 (default) + Fable 5 (hardest tasks) + + + + + + + + + + + + + + + + + + + + + + + + + + — not used — + Google Gemini (free tier) + ChatGPT / OpenAI (paid) + xAI Grok + DeepSeek (cheap) + Qwen (DashScope) + OpenRouter (has free models) + Groq (free tier) + Ollama (local, free) + LM Studio (local, free) + Custom (any OpenAI-compatible) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +