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

- New desktop conversations remember the last model explicitly selected in the
Composer and restore reasoning preferences separately for each provider/model.
Unavailable models fall back to Settings; permissions keep their global default.

Released, localized in-app notes remain in `packages/shared/src/changelog.ts`.
40 changes: 17 additions & 23 deletions apps/desktop/src/components/Composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
stripInlineComposerFileReferenceTokens,
} from "@pi-desktop/shared";
import { useAppStore } from "../stores/app-store";
import { newSessionModelConfiguration } from "../lib/composer-model-preferences";
import { latestTurnContextInspector } from "../lib/latest-turn-context";
import { isActivePlanExecution } from "../lib/plan-mode-state";
import { headAsk, queuedAskCount } from "../lib/pending-asks";
Expand Down Expand Up @@ -323,18 +324,19 @@ export function Composer({
: sessionPermissionMode;
const composerPermissionMode: Exclude<PermissionMode, "inherit"> =
mode === "goal" ? "auto" : effectivePermissionMode;
const newModel = !activeSession
? newSessionModelConfiguration({
draft: draftConfiguration,
settings,
providers,
})
: undefined;
const provider = providers.find(
(candidate) =>
candidate.id ===
(activeSession?.providerId ??
(!activeSession ? draftConfiguration?.providerId : undefined) ??
settings?.defaultProviderId),
(candidate) => candidate.id ===
(activeSession?.providerId ?? newModel?.providerId ?? settings?.defaultProviderId),
);
const modelId =
activeSession?.modelId ??
(!activeSession ? draftConfiguration?.modelId : undefined) ??
settings?.defaultModelId ??
provider?.defaultModelId;
const modelId = activeSession?.modelId ?? newModel?.modelId ??
settings?.defaultModelId ?? provider?.defaultModelId;
const selectedModelCatalog = provider ? providerModels[provider.id] : undefined;
const catalogThinkingProvider = thinkingProviderForModel(
provider,
Expand All @@ -347,19 +349,11 @@ export function Composer({
activeSession,
catalogThinkingProvider,
});
const selectedBinding = provider?.models.find((candidate) =>
modelIdsMatch(candidate.id, modelId ?? ""),
);
// A draft without a session starts at the selected model's stored default
// thinking level, clamped onto that binding's enabled ladder.
const draftThinkingLevel = initialThinkingLevelForBinding(
selectedBinding,
thinkingProvider?.supportedThinkingLevels,
);
const sessionThinkingLevel =
activeSession?.thinkingLevel ??
(!activeSession ? draftConfiguration?.thinkingLevel : undefined) ??
draftThinkingLevel;
const sessionThinkingLevel = activeSession?.thinkingLevel ?? newModel?.thinkingLevel ??
initialThinkingLevelForBinding(
provider?.models.find((candidate) => modelIdsMatch(candidate.id, modelId ?? "")),
thinkingProvider?.supportedThinkingLevels,
);
const configuredThinkingLevel = isThinkingLevel(sessionThinkingLevel)
? sessionThinkingLevel
: "off";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,8 @@ import type {
ProviderPublic,
SessionThinkingLevel,
} from "@pi-desktop/shared";
import {
initialThinkingLevelForBinding,
modelIdsMatch,
} from "@pi-desktop/shared";
import { useAppStore } from "../../../../stores/app-store";
import { rememberedComposerThinking } from "../../../../lib/composer-model-preferences";
import {
composerModelMatchesQuery,
composerModelsForProvider,
Expand All @@ -18,7 +15,6 @@ import {
import { providerThinkingLevels } from "../../../../lib/session-thinking";
import {
sessionThinkingMenuLevels,
thinkingLevelForProvider,
thinkingProviderForModel,
type ComposerMenuView,
} from "../model";
Expand Down Expand Up @@ -83,7 +79,7 @@ export function useComposerModelMenu({
providerId: current.providerId,
modelId: current.modelId,
thinkingLevel: level,
});
}, { rememberModel: true });
},
onError: (error) => {
const current = thinkingConfigRef.current;
Expand Down Expand Up @@ -245,29 +241,27 @@ export function useComposerModelMenu({
};

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;
try {
const nextModelProvider = thinkingProviderForModel(
candidate,
nextModelId,
providerModels[candidate.id],
);
const nextBinding = candidate.models.find((entry) =>
modelIdsMatch(entry.id, nextModelId),
const nextThinkingLevel = rememberedComposerThinking(
candidate,
nextModelId,
nextModelProvider?.supportedThinkingLevels,
);
const nextThinkingLevel = activeSessionId
? thinkingLevelForProvider(nextModelProvider, thinkingLevel)
: initialThinkingLevelForBinding(
nextBinding,
nextModelProvider?.supportedThinkingLevels,
);
await configureActiveSession({
mode,
providerId: candidate.id,
modelId: nextModelId,
thinkingLevel: nextThinkingLevel,
});
}, { rememberModel: true });
setQuery("");
setView("root");
setModelHighlight(-1);
Expand Down
145 changes: 145 additions & 0 deletions apps/desktop/src/lib/composer-model-preferences.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import {
initialThinkingLevelForBinding,
isSessionThinkingLevel,
modelIdsMatch,
type ProviderPublic,
type SessionThinkingLevel,
} from "@pi-desktop/shared";
import {
inheritedSessionModelBinding,
type SessionModelRef,
type SessionModelSettings,
} 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,
): SessionThinkingLevel {
const binding = provider?.models.find((entry) =>
modelIdsMatch(entry.id, modelId ?? ""),
);
const choice = readChoices().find(
(entry) =>
entry.providerId === provider?.id &&
modelIdsMatch(entry.modelId, modelId ?? ""),
);
return initialThinkingLevelForBinding(
choice
? {
thinkingLevels: binding?.thinkingLevels ?? fallbackLevels ?? [],
defaultThinkingLevel: choice.thinkingLevel,
}
: binding,
fallbackLevels,
);
}

/** Draft > last explicit usable choice > Settings. Existing sessions do not call this. */
export function newSessionModelConfiguration({
draft,
settings,
providers,
}: {
draft?: (SessionModelRef & { thinkingLevel?: SessionThinkingLevel }) | null;
settings?: SessionModelSettings | null;
providers: readonly ProviderPublic[];
}): SessionModelRef & { thinkingLevel: SessionThinkingLevel } {
const last = readChoices()[0];
const rememberedProvider = providers.find(
(provider) =>
provider.id === last?.providerId &&
provider.enabled &&
(provider.hasSecret || provider.authKind === "none"),
);
const rememberedModel = rememberedProvider?.models.length
? rememberedProvider.models.find((entry) =>
modelIdsMatch(entry.id, last?.modelId ?? ""),
)?.id
: rememberedProvider?.defaultModelId &&
modelIdsMatch(rememberedProvider.defaultModelId, last?.modelId ?? "")
? rememberedProvider.defaultModelId
: undefined;
const explicitModel =
draft?.providerId !== undefined || draft?.modelId !== undefined;
const inherited = inheritedSessionModelBinding({
draft: explicitModel
? draft
: rememberedModel
? {
providerId: rememberedProvider?.id,
modelId: rememberedModel,
}
: null,
settings,
providers,
});
const provider = providers.find((entry) => entry.id === inherited.providerId);
return {
...inherited,
thinkingLevel:
draft?.thinkingLevel ??
rememberedComposerThinking(provider, inherited.modelId),
};
}
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 @@ -200,7 +200,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
18 changes: 3 additions & 15 deletions apps/desktop/src/stores/runtime/session-coordination.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,6 @@ import type {
} from "@pi-desktop/shared";
import {
contextCompactionMark,
initialThinkingLevelForBinding,
modelIdsMatch,
normalizeMode,
} from "@pi-desktop/shared";
import { api } from "../../lib/api";
Expand All @@ -18,7 +16,7 @@ import {
FORKED_SESSION_WINDOW,
} from "../../lib/session-fork";
import { EMPTY_SESSION_WINDOW } from "../../lib/session-create";
import { inheritedSessionModelBinding } from "../../lib/session-model";
import { newSessionModelConfiguration } from "../../lib/composer-model-preferences";
import {
clearSessionPanes,
retainSessionPane,
Expand Down Expand Up @@ -280,29 +278,19 @@ export function createSessionCoordination({
options && "draftConfiguration" in options
? options.draftConfiguration
: state.draftConfiguration;
const inherited = inheritedSessionModelBinding({
const inherited = newSessionModelConfiguration({
draft: draftConfig,
settings,
providers: state.providers,
});
const defaultProvider = state.providers.find(
(provider) => provider.id === inherited.providerId,
);
const inheritedBinding = defaultProvider?.models.find((candidate) =>
modelIdsMatch(candidate.id, inherited.modelId ?? ""),
);
const defaultThinkingLevel = initialThinkingLevelForBinding(
inheritedBinding,
defaultProvider?.supportedThinkingLevels,
);
const previousSessionId = state.activeSessionId;
revealEmptyCreatingSession(active);
let created: Awaited<ReturnType<typeof api.createSession>>;
try {
created = await api.createSession({
title: untitledTaskTitle(),
mode: draftConfig?.mode ?? normalizeMode(settings?.defaultMode),
thinkingLevel: draftConfig?.thinkingLevel ?? defaultThinkingLevel,
thinkingLevel: inherited.thinkingLevel,
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 { prepareTranscriptAction } from "../runtime/transcript-action";
import type {
Mode,
Expand Down Expand Up @@ -119,6 +120,7 @@ export function createSessionSlice({
| "configureActiveSession"
| "abortSession"
> {
let modelChoiceIntent = 0;
const refreshSessionList = createRefreshCoordinator(async () => {
const result = await api.listSessions();
set({ sessions: decorateSessions(result.sessions, get().sessionMeta) });
Expand Down Expand Up @@ -574,9 +576,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 @@ -602,6 +611,7 @@ export function createSessionSlice({
config,
),
);
rememberChoice();
set((state) => ({
sessions: state.sessions.map((session) =>
session.id === sessionId
Expand All @@ -617,6 +627,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
Loading
Loading