diff --git a/desktop/frontend/src/App.tsx b/desktop/frontend/src/App.tsx index 088edcc4e1..38536d30f9 100644 --- a/desktop/frontend/src/App.tsx +++ b/desktop/frontend/src/App.tsx @@ -43,6 +43,7 @@ import { useGoalActionHandler } from "./lib/goalAction"; import { useWailsResizeFix } from "./lib/useWailsResizeFix"; import { asArray } from "./lib/array"; import { createBoundedRefreshCoordinator, sameTabMetaLists, shouldRefreshTabMetaForEvent, TAB_META_MAX_IN_FLIGHT, tabMetaFallbackDelay } from "./lib/tabMetaRefresh"; +import { forceWindowsDpiRepaint } from "./lib/dpiScale"; import { clearLegacyLangPref, normalizeLangPref, readLegacyLangPref, t, useI18n, useT, type Translator } from "./lib/i18n"; import { localizedNoticeText, useController, type Item, type LiveStream } from "./lib/useController"; import { app, onEvent, onProjectTreeChanged, onReady, onRuntimeRebuilt, onSessionRecovered, openExternal } from "./lib/bridge"; @@ -2610,8 +2611,11 @@ export default function App() { schedule(); }; const onVisibilityChange = () => { - if (document.visibilityState === "visible") refreshAndSchedule(); - else { + if (document.visibilityState === "visible") { + // Minimise/restore on Windows can leave a stale WebView2 scale layer. + forceWindowsDpiRepaint(); + refreshAndSchedule(); + } else { if (timer !== undefined) window.clearTimeout(timer); schedule(); } diff --git a/desktop/frontend/src/__tests__/dpi-repaint.test.tsx b/desktop/frontend/src/__tests__/dpi-repaint.test.tsx new file mode 100644 index 0000000000..900211bec5 --- /dev/null +++ b/desktop/frontend/src/__tests__/dpi-repaint.test.tsx @@ -0,0 +1,43 @@ +// Run: tsx src/__tests__/dpi-repaint.test.tsx +import { JSDOM } from "jsdom"; +import { forceWindowsDpiRepaint } from "../lib/dpiScale"; + +let passed = 0; +let failed = 0; +function ok(v: boolean, label: string) { + if (v) { process.stdout.write(` PASS ${label}\n`); passed++; } + else { process.stdout.write(` FAIL ${label}\n`); failed++; } +} + +const dom = new JSDOM("", { + pretendToBeVisual: true, + url: "http://localhost/", +}); +const g = globalThis as typeof globalThis & { + window: Window & typeof globalThis; + document: Document; + navigator: Navigator; + requestAnimationFrame: typeof requestAnimationFrame; +}; +g.window = dom.window as unknown as Window & typeof globalThis; +g.document = dom.window.document; +Object.defineProperty(g, "navigator", { + configurable: true, + value: { platform: "Win32", userAgent: "Windows" }, +}); +let raf = 0; +const runRaf = (cb: FrameRequestCallback) => { + raf += 1; + cb(0); + return raf; +}; +g.requestAnimationFrame = runRaf as typeof requestAnimationFrame; +dom.window.requestAnimationFrame = runRaf as typeof requestAnimationFrame; + +forceWindowsDpiRepaint(); +ok(true, "forceWindowsDpiRepaint does not throw on Windows"); +ok(raf >= 1, "schedules a repaint frame on Windows"); +ok(dom.window.document.documentElement.style.getPropertyValue("transform") === "", "transform cleaned up after repaint frame"); + +console.log(`\n${passed} passed, ${failed} failed`); +if (failed) process.exit(1); diff --git a/desktop/frontend/src/lib/dpiScale.ts b/desktop/frontend/src/lib/dpiScale.ts index 1e0431fd63..f0ae441661 100644 --- a/desktop/frontend/src/lib/dpiScale.ts +++ b/desktop/frontend/src/lib/dpiScale.ts @@ -77,3 +77,27 @@ export function saveRestartZoom(userZoom: ZoomLevel): void { export function initDpiScale(): void { /* zoom is handled entirely by the Go side (ZoomFactor) */ } + +/** + * Force a compositor refresh after Windows minimise/restore or DPI-related + * visibility changes. WebView2 can keep a rasterization scale/layer from the + * pre-minimise monitor, which shows as overall UI blow-up or blurry text + * (#7794, #7799) until the next full repaint. + * + * Safe no-op outside a browser/DOM environment. + */ +export function forceWindowsDpiRepaint(): void { + if (typeof document === "undefined" || typeof window === "undefined") return; + const platform = typeof navigator !== "undefined" ? navigator.platform || navigator.userAgent || "" : ""; + if (!/Win/i.test(platform)) return; + + const root = document.documentElement; + // A one-frame transform forces WebView2 to re-rasterize without changing layout. + const prev = root.style.getPropertyValue("transform"); + root.style.setProperty("transform", "translateZ(0)"); + void root.offsetHeight; + window.requestAnimationFrame(() => { + if (prev) root.style.setProperty("transform", prev); + else root.style.removeProperty("transform"); + }); +} diff --git a/internal/config/backfill_test.go b/internal/config/backfill_test.go index e2d4f2aa86..f0711f23ec 100644 --- a/internal/config/backfill_test.go +++ b/internal/config/backfill_test.go @@ -748,6 +748,7 @@ func TestNormalizeLegacyOpenCodeGoKimiK3CatalogPreservesVisionChoices(t *testing }{ {name: "explicitly disabled", vision: []string{}, want: []string{}}, {name: "custom list", vision: []string{"mimo-v2.5"}, want: []string{"mimo-v2.5"}}, + {name: "previous stock list gains mimo vision", vision: []string{"kimi-k3"}, want: []string{"kimi-k3", "mimo-v2.5"}}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -764,6 +765,27 @@ func TestNormalizeLegacyOpenCodeGoKimiK3CatalogPreservesVisionChoices(t *testing } } +func TestNormalizeOpenCodeGoMimoVisionUpgradesStockCatalog(t *testing.T) { + c := &Config{Providers: []ProviderEntry{{ + Name: "opencode-go", + Kind: "openai", + BaseURL: "https://opencode.ai/zen/go/v1", + Models: append([]string(nil), opencodeGoModels...), + VisionModels: []string{"kimi-k3"}, + PresetID: "opencode-go", + }}} + if !normalizeOpenCodeGoMimoVision(c) { + t.Fatal("expected mimo vision upgrade for stock kimi-k3-only list") + } + if !c.Providers[0].HasVisionModel("mimo-v2.5") || !c.Providers[0].HasVisionModel("kimi-k3") { + t.Fatalf("vision models = %#v, want kimi-k3 and mimo-v2.5", c.Providers[0].VisionModels) + } + // Second pass is a no-op. + if normalizeOpenCodeGoMimoVision(c) { + t.Fatal("second migration pass should not report a change") + } +} + func TestNormalizeDesktopOfficialProviderAccessCanonicalizesOnlyDeepSeekIDs(t *testing.T) { c := Default() c.DefaultModel = "deepseek-flash/deepseek-v4-pro" diff --git a/internal/config/load.go b/internal/config/load.go index db3ff02d46..85b54a7b35 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -220,6 +220,7 @@ func loadForRoot(root string, migrateOnDisk bool) (*Config, error) { normalizeLegacyQwenContextWindows(cfg) normalizeLegacyKimiK3Catalog(cfg) normalizeLegacyOpenCodeGoKimiK3Catalog(cfg) + normalizeOpenCodeGoMimoVision(cfg) normalizeLegacyMimoCustomProviders(cfg) normalizeLegacyProviderModels(cfg) normalizeDesktopOfficialProviderAccess(cfg) @@ -760,6 +761,7 @@ func normalizeConfigForEdit(cfg *Config) bool { changed = normalizeLegacyQwenContextWindows(cfg) || changed changed = normalizeLegacyKimiK3Catalog(cfg) || changed changed = normalizeLegacyOpenCodeGoKimiK3Catalog(cfg) || changed + changed = normalizeOpenCodeGoMimoVision(cfg) || changed changed = normalizeLegacyMimoCustomProviders(cfg) || changed normalizeLegacyProviderModels(cfg) normalizeDesktopOfficialProviderAccess(cfg) @@ -1385,6 +1387,49 @@ func migrateKimiK3VisionModels(current, legacy []string) []string { return mergeModelLists([]string{"kimi-k3"}, current) } +// migrateOpenCodeGoVisionModels upgrades stock OpenCode Go vision catalogs so +// multimodal models already present in Models gain image input. An explicit +// empty vision_models list remains a user disable signal. Only nil (never set) +// and the previous stock list {"kimi-k3"} are upgraded; any other list is a +// deliberate user customization and is left untouched. +func migrateOpenCodeGoVisionModels(current []string) []string { + if current != nil && len(current) == 0 { + return current + } + if current != nil && !stringSlicesEqual(current, []string{"kimi-k3"}) { + return current + } + return mergeModelLists(opencodeGoVisionModels, current) +} + +// normalizeOpenCodeGoMimoVision upgrades existing opencode-go installs that list +// mimo-v2.5 as a chat model but still carry a stock vision list without it. +// Without this, mid-session switches to opencode-go/mimo-v2.5 never attach +// image payloads even though a fresh preset would (#7798). +func normalizeOpenCodeGoMimoVision(c *Config) bool { + if c == nil { + return false + } + changed := false + for i := range c.Providers { + p := &c.Providers[i] + presetID := strings.TrimSpace(p.PresetID) + if (presetID != "opencode-go" && (presetID != "" || strings.TrimSpace(p.Name) != "opencode-go")) || + !strings.EqualFold(strings.TrimSpace(p.Kind), "openai") || + normalizedBaseURLForMigration(p.BaseURL) != "https://opencode.ai/zen/go/v1" || + !p.HasModel("mimo-v2.5") { + continue + } + next := migrateOpenCodeGoVisionModels(p.VisionModels) + if stringSlicesEqual(next, p.VisionModels) { + continue + } + p.VisionModels = next + changed = true + } + return changed +} + func mergeMissingKimiK3Override(p *ProviderEntry, defaults ProviderModelOverride) { if p.ModelOverrides == nil { p.ModelOverrides = map[string]ProviderModelOverride{} @@ -1431,7 +1476,7 @@ func normalizeLegacyOpenCodeGoKimiK3Catalog(c *Config) bool { continue } p.Models = append([]string(nil), opencodeGoModels...) - p.VisionModels = migrateKimiK3VisionModels(p.VisionModels, nil) + p.VisionModels = migrateOpenCodeGoVisionModels(p.VisionModels) mergeMissingKimiK3Override(p, ProviderModelOverride{ ReasoningProtocol: ReasoningProtocolOpenAI, SupportedEfforts: []string{"high", "max"}, diff --git a/internal/config/provider_presets.go b/internal/config/provider_presets.go index e62f6abbab..f3e63615b6 100644 --- a/internal/config/provider_presets.go +++ b/internal/config/provider_presets.go @@ -106,8 +106,12 @@ var ( stepfunPlanModels = []string{"step-3.7-flash", "step-3.5-flash", "step-3.5-flash-2603"} legacyOpenCodeGoModels = []string{"glm-5.2", "glm-5.1", "kimi-k2.7-code", "kimi-k2.6", "deepseek-v4-pro", "deepseek-v4-flash", "mimo-v2.5-pro", "mimo-v2.5"} - opencodeGoModels = []string{"glm-5.2", "glm-5.1", "kimi-k3", "kimi-k2.7-code", "kimi-k2.6", "deepseek-v4-pro", "deepseek-v4-flash", "mimo-v2.5-pro", "mimo-v2.5"} - opencodeGoVisionModels = []string{"kimi-k3"} + opencodeGoModels = []string{"glm-5.2", "glm-5.1", "kimi-k3", "kimi-k2.7-code", "kimi-k2.6", "deepseek-v4-pro", "deepseek-v4-flash", "mimo-v2.5-pro", "mimo-v2.5"} + // mimo-v2.5 is multimodal on the OpenCode Go relay; without listing it here, + // mid-session switches from a text-only default (e.g. deepseek-v4-flash) to + // opencode-go/mimo-v2.5 keep imageInputEnabled false and only attach path text + // (#7798). mimo-v2.5-pro stays text-only, matching the official MiMo catalog. + opencodeGoVisionModels = []string{"kimi-k3", "mimo-v2.5"} opencodeGoAnthropicModels = []string{"qwen3.7-plus", "qwen3.7-max", "qwen3.6-plus", "minimax-m3", "minimax-m2.7", "minimax-m2.5"} opencodeZenAnthropicModels = []string{"claude-sonnet-4-6", "claude-opus-4-8", "claude-haiku-4-5", "qwen3.6-plus", "qwen3.5-plus", "qwen3.6-plus-free"} opencodeZenAnthropicVisionModels = []string{"claude-sonnet-4-6", "claude-opus-4-8", "claude-haiku-4-5"} diff --git a/internal/config/provider_presets_test.go b/internal/config/provider_presets_test.go index ae07cf4c33..3cf38c91bd 100644 --- a/internal/config/provider_presets_test.go +++ b/internal/config/provider_presets_test.go @@ -501,6 +501,18 @@ func TestCuratedProviderPresetCapabilities(t *testing.T) { if !ok { t.Fatal("opencode-go/kimi-k3 did not resolve") } + mimoVision, ok := cfg.ResolveModel("opencode-go/mimo-v2.5") + if !ok || !EffectiveVision(mimoVision) || !ExplicitModelVision(mimoVision) { + t.Fatalf("opencode-go/mimo-v2.5 must be vision-capable for mid-session switches: ok=%v entry=%+v", ok, mimoVision) + } + mimoPro, ok := cfg.ResolveModel("opencode-go/mimo-v2.5-pro") + if !ok || EffectiveVision(mimoPro) { + t.Fatalf("opencode-go/mimo-v2.5-pro must stay text-only: ok=%v entry=%+v", ok, mimoPro) + } + flash, ok := cfg.ResolveModel("opencode-go/deepseek-v4-flash") + if !ok || EffectiveVision(flash) { + t.Fatalf("opencode-go/deepseek-v4-flash must stay text-only: ok=%v entry=%+v", ok, flash) + } if protocol := ReasoningProtocolForEntry(kimiK3); protocol != ReasoningProtocolOpenAI { t.Fatalf("opencode Kimi K3 protocol = %q, want openai", protocol) } diff --git a/internal/control/inputimages_test.go b/internal/control/inputimages_test.go index e126e33ce6..abb281e348 100644 --- a/internal/control/inputimages_test.go +++ b/internal/control/inputimages_test.go @@ -138,3 +138,45 @@ func TestControllerImageInputEnabledDoesNotFallbackFromUnknownRef(t *testing.T) t.Fatal("unknown ref should not inherit image input from the default fallback model") } } + +// Mid-session model switches must re-evaluate vision from the new modelRef. +// A text-only default (deepseek-v4-flash style) followed by a switch to a +// vision model on the same provider must start attaching image payloads +// without requiring a new session (#7798). +func TestControllerInputImagesFollowsMidSessionModelSwitch(t *testing.T) { + workspace := t.TempDir() + cfg := config.Default() + cfg.DefaultModel = "relay/text-only" + cfg.Providers = []config.ProviderEntry{{ + Name: "relay", + Kind: "openai", + BaseURL: "https://example.invalid/v1", + Models: []string{"text-only", "vision-model"}, + VisionModels: []string{"vision-model"}, + }} + if err := cfg.SaveTo(filepath.Join(workspace, "reasonix.toml")); err != nil { + t.Fatalf("save workspace config: %v", err) + } + path := filepath.Join(workspace, "diagram.png") + if err := os.WriteFile(path, mustBase64(t, tinyPNG), 0o644); err != nil { + t.Fatal(err) + } + + c := &Controller{workspaceRoot: workspace, modelRef: "relay/text-only"} + if c.imageInputEnabled() { + t.Fatal("text-only default must not enable image input") + } + if urls := c.inputImages("look at @diagram.png"); len(urls) != 0 { + t.Fatalf("text-only session should suppress images, got %v", urls) + } + + // Simulate SetModel rebuild: only modelRef changes on the live controller + // path that imageInputEnabled re-resolves against config. + c.modelRef = "relay/vision-model" + if !c.imageInputEnabled() { + t.Fatal("mid-session switch to vision model must enable image input") + } + if urls := c.inputImages("look at @diagram.png"); len(urls) != 1 { + t.Fatalf("vision model after switch should attach image payload, got %v", urls) + } +}