Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions desktop/frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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();
}
Expand Down
43 changes: 43 additions & 0 deletions desktop/frontend/src/__tests__/dpi-repaint.test.tsx
Original file line number Diff line number Diff line change
@@ -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("<!doctype html><html><body></body></html>", {
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);
24 changes: 24 additions & 0 deletions desktop/frontend/src/lib/dpiScale.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
}
22 changes: 22 additions & 0 deletions internal/config/backfill_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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"
Expand Down
47 changes: 46 additions & 1 deletion internal/config/load.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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{}
Expand Down Expand Up @@ -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"},
Expand Down
8 changes: 6 additions & 2 deletions internal/config/provider_presets.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Expand Down
12 changes: 12 additions & 0 deletions internal/config/provider_presets_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
42 changes: 42 additions & 0 deletions internal/control/inputimages_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Loading