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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Changelog

## Unreleased

- Restore Composer reasoning preferences separately for each provider/model.
Keep upstream model inheritance based on successful message submission and
global permission defaults unchanged.

Released, localized in-app notes remain in `packages/shared/src/changelog.ts`.
5 changes: 4 additions & 1 deletion apps/desktop/src/components/Composer.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { rememberedComposerThinking } from "../lib/composer-model-preferences";
import { isRemoteSession, sessionAllows } from "../lib/remote-session-safety";
import { inheritedSessionModelBinding } from "../lib/session-model";
import {
Expand Down Expand Up @@ -381,7 +382,9 @@ export function Composer({
const sessionThinkingLevel =
activeSession?.thinkingLevel ??
(!activeSession ? draftConfiguration?.thinkingLevel : undefined) ??
draftThinkingLevel;
(!activeSession
? rememberedComposerThinking(provider, modelId, thinkingProvider?.supportedThinkingLevels, draftThinkingLevel, selectedModelInfo?.catalogSource)
: draftThinkingLevel);
const configuredThinkingLevel = isThinkingLevel(sessionThinkingLevel)
? sessionThinkingLevel
: "off";
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { rememberedComposerThinking } from "../../../../lib/composer-model-preferences";
import { sameRecentModel } from "../../../../lib/recent-models";
import type {
Mode,
Expand Down Expand Up @@ -40,7 +41,7 @@ type UseComposerModelMenuOptions = {
controlsBlocked: boolean;
configureActiveSession: (configuration: {
mode: Mode; providerId?: string; modelId?: string; thinkingLevel: SessionThinkingLevel;
}) => Promise<void>;
}, options?: { rememberModel?: boolean }) => Promise<void>;
};

export function useComposerModelMenu({
Expand Down Expand Up @@ -96,7 +97,7 @@ export function useComposerModelMenu({
providerId: current.providerId,
modelId: current.modelId,
thinkingLevel: level,
});
}, { rememberModel: true });
},
onError: (error) => {
const current = thinkingConfigRef.current;
Expand Down Expand Up @@ -235,8 +236,10 @@ export function useComposerModelMenu({
}, [modelHighlight, open]);

const selectModel = async (candidate: ProviderPublic, nextModelId: string) => {
const sessionId = useAppStore.getState().activeSessionId;
thinkingQueueRef.current?.invalidate();
await thinkingQueueRef.current?.idle();
if (useAppStore.getState().activeSessionId !== sessionId) return;
if (isImageGenerationModel(
imageGenerationBindings(
useAppStore.getState().settings?.imageGenerationModels,
Expand All @@ -263,21 +266,22 @@ export function useComposerModelMenu({
sameComposerModelId(modelId ?? "", nextModelId);
const nextThinkingLevel = selectedSameModel
? thinkingLevelForProvider(nextModelProvider, thinkingLevel)
: (nextModel?.catalogSource === "models.dev"
: rememberedComposerThinking(candidate, nextModelId, nextModelProvider?.supportedThinkingLevels,
(nextModel?.catalogSource === "models.dev"
? initialThinkingLevelForBinding(
nextBinding,
nextModelProvider?.supportedThinkingLevels,
)
: initialThinkingLevelForUnmatchedModel(
nextBinding,
nextModelProvider?.supportedThinkingLevels,
));
)), nextModel?.catalogSource);
await configureActiveSession({
mode,
providerId: candidate.id,
modelId: nextModelId,
thinkingLevel: nextThinkingLevel,
});
}, { rememberModel: true });
setQuery("");
setOtherModelsExpanded(false);
setModelHighlight(-1);
Expand Down
99 changes: 99 additions & 0 deletions apps/desktop/src/lib/composer-model-preferences.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import {
initialThinkingLevelForBinding,
initialThinkingLevelForUnmatchedModel,
type ModelInfo,
isSessionThinkingLevel,
modelIdsMatch,
type ProviderPublic,
type SessionThinkingLevel,
} from "@pi-desktop/shared";
import type { SessionModelRef } from "./session-model";

export const COMPOSER_MODEL_PREFERENCES_KEY =
"pi.desktop.composerModelPreferences.v1";
const MAX_CHOICES = 100;
type Choice = Required<SessionModelRef> & {
thinkingLevel: SessionThinkingLevel;
};

function isChoice(value: unknown): value is Choice {
if (!value || typeof value !== "object") return false;
const row = value as Record<string, unknown>;
return (
typeof row.providerId === "string" &&
row.providerId.length > 0 &&
typeof row.modelId === "string" &&
row.modelId.length > 0 &&
isSessionThinkingLevel(row.thinkingLevel)
);
}

function readChoices(): Choice[] {
try {
const raw: unknown = JSON.parse(
globalThis.localStorage?.getItem(COMPOSER_MODEL_PREFERENCES_KEY) ??
"null",
);
return Array.isArray(raw) ? raw.filter(isChoice).slice(0, MAX_CHOICES) : [];
} catch {
// Missing, blocked or corrupt device preferences fall back to Settings.
return [];
}
}

/** Only explicit Composer model/reasoning actions call this, never session navigation. */
export function rememberComposerModel(
choice: SessionModelRef & { thinkingLevel: SessionThinkingLevel },
): void {
if (!isChoice(choice)) return;
const choices = readChoices().filter(
(entry) =>
entry.providerId !== choice.providerId ||
!modelIdsMatch(entry.modelId, choice.modelId),
);
try {
globalThis.localStorage?.setItem(
COMPOSER_MODEL_PREFERENCES_KEY,
JSON.stringify(
[
{
providerId: choice.providerId,
modelId: choice.modelId,
thinkingLevel: choice.thinkingLevel,
},
...choices,
].slice(0, MAX_CHOICES),
),
);
} catch {
// Optional device preferences must not prevent configuring a session.
}
}

export function rememberedComposerThinking(
provider: ProviderPublic | undefined,
modelId: string | undefined,
fallbackLevels = provider?.supportedThinkingLevels,
fallback: SessionThinkingLevel = initialThinkingLevelForBinding(
provider?.models.find((entry) => modelIdsMatch(entry.id, modelId ?? "")),
fallbackLevels,
),
catalogSource?: ModelInfo["catalogSource"],
): SessionThinkingLevel {
const binding = provider?.models.find((entry) =>
modelIdsMatch(entry.id, modelId ?? ""),
);
const choice = readChoices().find(
(entry) =>
entry.providerId === provider?.id &&
modelIdsMatch(entry.modelId, modelId ?? ""),
);
if (!choice) return fallback;
const resolveThinking = catalogSource === "models.dev"
? initialThinkingLevelForBinding
: initialThinkingLevelForUnmatchedModel;
return resolveThinking(
{ ...binding, defaultThinkingLevel: choice.thinkingLevel },
fallbackLevels,
);
}
2 changes: 1 addition & 1 deletion apps/desktop/src/stores/app-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,7 @@ export type AppState = {
modelId?: string;
thinkingLevel: SessionThinkingLevel;
permissionMode?: PermissionMode;
}) => Promise<void>;
}, options?: { rememberModel?: boolean }) => Promise<void>;
/** Returns true once accepted unless concurrent smart Stop restores it. */
sendPrompt: (
content: string,
Expand Down
5 changes: 4 additions & 1 deletion apps/desktop/src/stores/runtime/session-coordination.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { rememberedComposerThinking } from "../../lib/composer-model-preferences";
import type {
ContextCompactionRecord,
SessionDetail,
Expand Down Expand Up @@ -317,7 +318,9 @@ export function createSessionCoordination({
created = await api.createSession({
title: untitledTaskTitle(),
mode: draftConfig?.mode ?? normalizeMode(settings?.defaultMode),
thinkingLevel: draftConfig?.thinkingLevel ?? defaultThinkingLevel,
thinkingLevel: draftConfig?.thinkingLevel ?? rememberedComposerThinking(
defaultProvider, inherited.modelId, defaultProvider?.supportedThinkingLevels, defaultThinkingLevel, catalogModel?.catalogSource,
),
permissionMode: draftConfig?.permissionMode,
providerId: inherited.providerId,
modelId: inherited.modelId,
Expand Down
13 changes: 12 additions & 1 deletion apps/desktop/src/stores/slices/session-slice.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import i18n from "i18next";
import { rememberComposerModel } from "../../lib/composer-model-preferences";
import type {
Mode,
PendingInteractiveRequests,
Expand Down Expand Up @@ -170,6 +171,7 @@ export function createSessionSlice({
> {
const pendingInteractiveRestoreGenerations = new Map<string, number>();

let modelChoiceIntent = 0;
const refreshSessionList = createRefreshCoordinator(async () => {
const result = await api.listSessions();
set((state) => {
Expand Down Expand Up @@ -778,9 +780,16 @@ export function createSessionSlice({
}
},

configureActiveSession: async (config) => {
configureActiveSession: async (config, options) => {
const sessionId = get().activeSessionId;
const choiceIntent = options?.rememberModel ? ++modelChoiceIntent : undefined;
const rememberChoice = () => {
if (choiceIntent !== undefined && choiceIntent === modelChoiceIntent) {
rememberComposerModel(config);
}
};
if (!sessionId) {
rememberChoice();
set((state) => ({
draftConfiguration: {
mode: config.mode,
Expand All @@ -807,6 +816,7 @@ export function createSessionSlice({
config,
),
);
rememberChoice();
set((state) => ({
sessions: state.sessions.map((session) =>
session.id === sessionId
Expand All @@ -822,6 +832,7 @@ export function createSessionSlice({
);
runtime.pendingSessionConfigurations.delete(sessionId);
const result = await api.configureSession(sessionId, payload);
rememberChoice();
set((state) => ({
sessions: state.sessions.map((session) =>
session.id === sessionId
Expand Down
122 changes: 122 additions & 0 deletions apps/desktop/test/composer-model-preferences.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import assert from "node:assert/strict";
import { register } from "node:module";
import test, { beforeEach } from "node:test";
register(new URL("./helpers/ts-import-hooks.mjs", import.meta.url));
const {
COMPOSER_MODEL_PREFERENCES_KEY: key,
rememberComposerModel,
rememberedComposerThinking,
} = await import("../src/lib/composer-model-preferences.ts");
const values = new Map();
beforeEach(() => {
values.clear();
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: {
getItem: (key) => values.get(key) ?? null,
setItem: (key, value) => values.set(key, value),
},
});
});
const providers = ["a", "b"].map((id) => ({
id,
enabled: true,
authKind: "none",
models: [
{
id: "one",
thinkingLevels: ["low", "high"],
defaultThinkingLevel: "high",
},
{ id: "two", thinkingLevels: ["off"] },
],
}));
const remember = (providerId, modelId, thinkingLevel) =>
rememberComposerModel({ providerId, modelId, thinkingLevel });

test("unremembered models preserve caller defaults including unmatched catalog behavior", () => {
assert.equal(rememberedComposerThinking(providers[0], "one", undefined, "omit"), "omit");
remember("a", "one", "low");
assert.equal(rememberedComposerThinking(providers[0], "one", undefined, "omit"), "low");
});

test("thinking is isolated by provider and model, including omit", () => {
remember("a", "one", "low");
remember("b", "one", "omit");
remember("a", "two", "off");
assert.equal(rememberedComposerThinking(providers[0], "one"), "low");
assert.equal(rememberedComposerThinking(providers[1], "one"), "omit");
assert.equal(rememberedComposerThinking(providers[0], "two"), "off");
});

test("permissions and operating mode never enter the preference", () => {
rememberComposerModel({
providerId: "b",
modelId: "one",
thinkingLevel: "low",
permissionMode: "auto",
mode: "goal",
});
assert.equal(values.get(key).includes("auto"), false);
assert.equal(values.get(key).includes("goal"), false);
});

test("changed capabilities clamp saved thinking and never send omit to a non-reasoning model", () => {
remember("a", "one", "low");
const changed = {
...providers[0],
models: [{ id: "one", thinkingLevels: ["high"] }],
};
assert.equal(rememberedComposerThinking(changed, "one"), "high");
remember("a", "one", "omit");
assert.equal(
rememberedComposerThinking(
{ ...changed, models: [{ id: "one", thinkingLevels: ["off"] }] },
"one",
),
"off",
);
});

test("invalid data and unavailable storage degrade to defaults without blocking", () => {
for (const raw of [
"{",
"null",
"{}",
'[{"providerId":"b","modelId":"one","thinkingLevel":"invalid"}]',
]) {
values.set(key, raw);
assert.equal(rememberedComposerThinking(providers[0], "one", undefined, "high"), "high");
}
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
get() {
throw new Error("blocked");
},
});
assert.doesNotThrow(() => remember("b", "one", "low"));
assert.equal(rememberedComposerThinking(providers[0], "one", undefined, "high"), "high");
});

test("history stays bounded and repeated selections replace the matching entry", () => {
for (let index = 0; index < 105; index += 1)
remember("a", `model-${index}`, "low");
remember("a", "model-104", "high");
const saved = JSON.parse(values.get(key));
assert.equal(saved.length, 100);
assert.equal(saved[0].thinkingLevel, "high");
assert.equal(
saved.filter((entry) => entry.modelId === "model-104").length,
1,
);
});

test("generic empty bindings restore reasoning while known empty bindings remain disabled", () => {
const generic = { ...providers[0], models: [{ id: "one", thinkingLevels: [] }] };
for (const level of ["low", "omit"]) {
remember("a", "one", level);
assert.equal(rememberedComposerThinking(generic, "one", undefined, "off"), level);
assert.equal(rememberedComposerThinking(generic, "one", undefined, "off", "models.dev"), "off");
assert.equal(rememberedComposerThinking({ ...generic, models: [{ id: "one", thinkingLevels: ["off"] }] }, "one", undefined, "off"), "off");
}
});
Loading
Loading