diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index 5b42c28adc518..48440dd21b5db 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -74,6 +74,7 @@ import { isAtomCommandInterrupted, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; +import { resolveProjectLastUsedSettings } from "@t3tools/client-runtime/state/project-last-used-settings"; import { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; import { type VcsRef } from "@t3tools/client-runtime/state/vcs"; import { @@ -407,8 +408,19 @@ export function NewTaskFlowProvider( const draftStartFromOrigin = selectedProjectDraft.workspaceSelection?.startFromOrigin; const startFromOrigin = isGitRepo && (draftStartFromOrigin ?? projectSettings.settings.newWorktreesStartFromOrigin); - const defaultRuntimeMode = projectSettings.settings.defaultRuntimeMode; + const lastUsed = selectedProject + ? resolveProjectLastUsedSettings(threads, { + environmentId: selectedProject.environmentId, + projectId: selectedProject.id, + }) + : null; const managedPolicy = selectedEnvironmentServerConfig?.managedPolicy; + // A mode configured on the project wins over its history, which yields to the policy ceiling. + const defaultRuntimeMode = resolveNewThreadRuntimeMode( + projectSettings.sources.defaultRuntimeMode === "project" ? undefined : lastUsed?.runtimeMode, + projectSettings.settings.defaultRuntimeMode, + managedPolicy, + ); const runtimeMode = resolveNewThreadRuntimeMode( selectedProjectDraft.runtimeMode, defaultRuntimeMode, @@ -422,11 +434,24 @@ export function NewTaskFlowProvider( selectedProjectDraft.modelSelection ?? null, selectedProject?.id ?? null, ); - const projectDefaultModelSelection = resolveDefaultableModelSelection( + // A model configured on the project wins over its history; the + // environment's default only applies after the project's history. + const configuredDefaultModelSelection = resolveDefaultableModelSelection( selectedEnvironmentServerConfig, projectSettings.settings.defaultModelSelection, selectedProject?.id ?? null, ); + const projectDefaultModelSelection = + projectSettings.sources.defaultModelSelection === "environment" + ? null + : configuredDefaultModelSelection; + const environmentDefaultModelSelection = + projectDefaultModelSelection === null ? configuredDefaultModelSelection : null; + const lastUsedModelSelection = resolveDefaultableModelSelection( + selectedEnvironmentServerConfig, + lastUsed?.modelSelection ?? null, + selectedProject?.id ?? null, + ); const stickyModelSelection = resolveDefaultableModelSelection( selectedEnvironmentServerConfig, storedStickyModelSelection, @@ -434,7 +459,11 @@ export function NewTaskFlowProvider( ); const modelOptions = buildModelOptions( selectedEnvironmentServerConfig, - draftModelSelection ?? projectDefaultModelSelection ?? stickyModelSelection, + draftModelSelection ?? + projectDefaultModelSelection ?? + lastUsedModelSelection ?? + environmentDefaultModelSelection ?? + stickyModelSelection, undefined, selectedProject?.id ?? null, ); @@ -442,13 +471,19 @@ export function NewTaskFlowProvider( const selectedDefaultModel = resolveNewTaskModelSelection({ draftSelection: draftModelSelection, projectDefaultSelection: projectDefaultModelSelection, + lastUsedSelection: lastUsedModelSelection, + environmentDefaultSelection: environmentDefaultModelSelection, stickySelection: stickyModelSelection, modelOptions, }); const selectedModel = resolveNewThreadModelSelection({ defaultSelection: selectedDefaultModel, explicitSelection: - draftModelSelection ?? (projectDefaultModelSelection ? null : stickyModelSelection), + draftModelSelection ?? + (projectDefaultModelSelection + ? null + : (lastUsedModelSelection ?? + (environmentDefaultModelSelection ? null : stickyModelSelection))), fallbackSelection: selectedEnvironmentServerConfig?.settings.fallbackModelSelection ?? null, providers: selectedEnvironmentServerConfig?.providers ?? [], ...(selectedEnvironmentServerConfig diff --git a/apps/mobile/src/lib/modelOptions.test.ts b/apps/mobile/src/lib/modelOptions.test.ts index bf03fed351076..b4c399c4df110 100644 --- a/apps/mobile/src/lib/modelOptions.test.ts +++ b/apps/mobile/src/lib/modelOptions.test.ts @@ -535,9 +535,11 @@ describe("mobile model options", () => { expect(resolveDefaultableModelSelection(null, legacy, null)).toBe(legacy); }); - it("resolves new tasks from draft, project, sticky, then provider defaults", () => { + it("resolves new tasks from draft, project, last-used, environment, sticky, then provider defaults", () => { const draft = { instanceId: ProviderInstanceId.make("codex"), model: "draft" }; const project = { instanceId: ProviderInstanceId.make("codex"), model: "project" }; + const lastUsed = { instanceId: ProviderInstanceId.make("codex"), model: "last-used" }; + const environment = { instanceId: ProviderInstanceId.make("claudeAgent"), model: "env" }; const sticky = { instanceId: ProviderInstanceId.make("codex"), model: "sticky" }; const providerDefault = { selection: { instanceId: ProviderInstanceId.make("codex"), model: "default" }, @@ -546,19 +548,26 @@ describe("mobile model options", () => { const resolve = ( draftSelection: ModelSelection | null, projectDefaultSelection: ModelSelection | null, + lastUsedSelection: ModelSelection | null, stickySelection: ModelSelection | null, + environmentDefaultSelection: ModelSelection | null = null, ) => resolveNewTaskModelSelection({ draftSelection, projectDefaultSelection, + lastUsedSelection, + environmentDefaultSelection, stickySelection, modelOptions: [providerDefault], }); - expect(resolve(draft, project, sticky)).toBe(draft); - expect(resolve(null, project, sticky)).toBe(project); - expect(resolve(null, null, sticky)).toBe(sticky); - expect(resolve(null, null, null)).toBe(providerDefault.selection); + expect(resolve(draft, project, lastUsed, sticky)).toBe(draft); + expect(resolve(null, project, lastUsed, sticky)).toBe(project); + expect(resolve(null, null, lastUsed, sticky)).toBe(lastUsed); + expect(resolve(null, null, lastUsed, sticky, environment)).toBe(lastUsed); + expect(resolve(null, null, null, sticky, environment)).toBe(environment); + expect(resolve(null, null, null, sticky)).toBe(sticky); + expect(resolve(null, null, null, null)).toBe(providerDefault.selection); const unavailable = { ...providerDefault, isUnavailable: true }; expect( diff --git a/apps/mobile/src/lib/modelOptions.ts b/apps/mobile/src/lib/modelOptions.ts index 7771c738042d3..8624f1d8bfbc6 100644 --- a/apps/mobile/src/lib/modelOptions.ts +++ b/apps/mobile/src/lib/modelOptions.ts @@ -263,12 +263,16 @@ export function resolveDefaultableModelSelection( export function resolveNewTaskModelSelection(input: { readonly draftSelection: ModelSelection | null; readonly projectDefaultSelection: ModelSelection | null; + readonly lastUsedSelection?: ModelSelection | null; + readonly environmentDefaultSelection?: ModelSelection | null; readonly stickySelection: ModelSelection | null; readonly modelOptions: ReadonlyArray; }): ModelSelection | null { return ( input.draftSelection ?? input.projectDefaultSelection ?? + input.lastUsedSelection ?? + input.environmentDefaultSelection ?? input.stickySelection ?? input.modelOptions.find((option) => option.isDefault && !option.isUnavailable)?.selection ?? input.modelOptions.find((option) => !option.isUnavailable)?.selection ?? diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index f4561188df0cc..fb9689d1c66b4 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -251,6 +251,7 @@ import { resolveThreadDetailRef, useEnvironmentSupportsServerBrowser, useProject, + useProjectLastUsedRuntimeMode, useQuietThreadProjection, useThreadUpdatedAt, useThreadLastVisitedAt, @@ -260,6 +261,8 @@ import { useQuietThreadShell, useThreadCheckpointSummaries, } from "../state/entities"; +import { resolveDefaultRuntimeMode } from "../lib/chatThreadActions"; +import { useManagedPolicy } from "../state/managedPolicy"; import { useEnvironmentShellHasSnapshot } from "../state/shell"; import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer"; import { DraftHeroHeadline } from "./chat/DraftHeroHeadline"; @@ -936,8 +939,17 @@ export default function ChatView(props: ChatViewProps) { const [, setThreadErrorBannerDismissTick] = useState(0); const threadErrorIsServerRuntimeError = localServerError === null && visibleThreadError === serverRuntime?.lastError; - const defaultRuntimeMode = resolveProjectSettings(settings, activeThread?.projectId ?? null) - .settings.defaultRuntimeMode; + const managedPolicy = useManagedPolicy(environmentId); + const draftLastUsedRuntimeMode = useProjectLastUsedRuntimeMode( + !isServerThread && activeThread + ? scopeProjectRef(activeThread.environmentId, activeThread.projectId) + : null, + ); + const defaultRuntimeMode = resolveDefaultRuntimeMode( + resolveProjectSettings(settings, activeThread?.projectId ?? null), + draftLastUsedRuntimeMode, + managedPolicy, + ); const runtimeMode = composerRuntimeMode ?? (isServerThread ? activeThread?.runtimeMode : undefined) ?? diff --git a/apps/web/src/components/chat/DraftHeroHeadline.tsx b/apps/web/src/components/chat/DraftHeroHeadline.tsx index 91bf642fcb32f..d3448014c5d64 100644 --- a/apps/web/src/components/chat/DraftHeroHeadline.tsx +++ b/apps/web/src/components/chat/DraftHeroHeadline.tsx @@ -16,7 +16,7 @@ import { useProjectPin } from "~/hooks/useProjectGroupUpdate"; import { useScratchProject } from "~/hooks/useScratchProject"; import { isTriagePrompt } from "~/hooks/useStartTriage"; import { useClientSettings } from "~/hooks/useSettings"; -import { hasExplicitComposerModelSelection } from "~/lib/chatThreadActions"; +import { retargetDraftToProject } from "~/lib/chatThreadActions"; import { deriveLogicalProjectKeyFromSettings, pickableProjectGroups, @@ -52,9 +52,6 @@ import { InlineButton } from "../ui/button"; import { DraftProjectName } from "./DraftProjectName"; import { DraftHeroProjectHeading } from "./DraftHeroProjectHeading"; import { TriageDraftHero } from "./TriageDraftHero"; -import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; -import { resolveProjectDefaultProviderModelSelection } from "../../providerInstances"; -import { resolveNewThreadModelSelection } from "@t3tools/shared/usageLimits"; const ADD_PROJECT_VALUE = "add-project"; @@ -81,16 +78,11 @@ export function DraftHeroHeadline({ const primaryEnvironmentId = usePrimaryEnvironmentId(); const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); const projectSortOrder = useClientSettings((settings) => settings.sidebarProjectSortOrder); - const setLogicalProjectDraftThreadId = useComposerDraftStore( - (store) => store.setLogicalProjectDraftThreadId, - ); const getComposerDraft = useComposerDraftStore((store) => store.getComposerDraft); // A boolean, so typing in a draft does not re-render the hero. const isTriageDraftPrompt = useComposerDraftStore((store) => draftId ? isTriagePrompt(store.getComposerDraft(draftId)?.prompt ?? "") : false, ); - const applyStickyState = useComposerDraftStore((store) => store.applyStickyState); - const setModelSelection = useComposerDraftStore((store) => store.setModelSelection); const openAddProject = () => openCommandPalette({ open: "add-project" }); const { scratchEnvironmentId, scratchWorkspaceRootFor, openScratchProject } = useScratchProject(); const openProjectDraft = useNewThreadHandler(); @@ -203,46 +195,20 @@ export function DraftHeroHeadline({ void openProjectDraft(scopeProjectRef(project.environmentId, project.id)); return; } - setLogicalProjectDraftThreadId( - logicalProjectKey, - scopeProjectRef(project.environmentId, project.id), + const targetServerConfig = environments.find( + (environment) => environment.environmentId === project.environmentId, + )?.serverConfig; + retargetDraftToProject({ draftId, - ); - if (!hasExplicitComposerModelSelection(currentDraft)) { - applyStickyState(draftId); - const environmentConfig = environments.find( - (environment) => environment.environmentId === project.environmentId, - )?.serverConfig; - const environmentSettings = environmentConfig?.settings; - const defaultSelection = environmentSettings - ? resolveProjectSettings(environmentSettings, project.id, project).settings - .defaultModelSelection - : project.defaultModelSelection; - const fallbackResolution = resolveNewThreadModelSelection({ - defaultSelection: - defaultSelection ?? - (useComposerDraftStore.getState().stickyActiveProvider !== null || !environmentSettings - ? null - : resolveProjectDefaultProviderModelSelection( - providersByEnvironment.get(project.environmentId) ?? [], - environmentSettings, - project.id, - )), - fallbackSelection: environmentSettings?.fallbackModelSelection ?? null, - providers: providersByEnvironment.get(project.environmentId) ?? [], - ...(environmentSettings ? { settings: environmentSettings } : {}), - projectId: project.id, - now: Date.now(), - }); - const defaultModelSelection = fallbackResolution.fallback - ? fallbackResolution.selection - : defaultSelection; - if (defaultModelSelection) { - setModelSelection(draftId, defaultModelSelection, { - replaceOptions: true, - }); - } - } + projectRef: scopeProjectRef(project.environmentId, project.id), + project, + environmentSettings: targetServerConfig?.settings, + managedPolicy: targetServerConfig?.managedPolicy, + providers: providersByEnvironment.get(project.environmentId) ?? [], + threads, + logicalProjectKey, + refreshModel: true, + }); }; const startScratch = async (): Promise => { if (scratchTargetEnvironmentId === null || isScratchDraft) { diff --git a/apps/web/src/components/chat/useDraftEnvironmentRouting.ts b/apps/web/src/components/chat/useDraftEnvironmentRouting.ts index 1e991899a5537..f26627863aeab 100644 --- a/apps/web/src/components/chat/useDraftEnvironmentRouting.ts +++ b/apps/web/src/components/chat/useDraftEnvironmentRouting.ts @@ -18,7 +18,8 @@ import { } from "@t3tools/client-runtime/state/project-grouping"; import { useComposerDraftStore, DraftId } from "../../composerDraftStore"; import { type EnvironmentPresentation, useEnvironmentProviders } from "../../state/environments"; -import { useProjects } from "../../state/entities"; +import { readThreadShells, useProjects } from "../../state/entities"; +import { retargetDraftToProject } from "../../lib/chatThreadActions"; import type { DraftSessionState } from "../../composerDraftStore"; interface DraftEnvironmentRoutingInput { @@ -68,9 +69,6 @@ export function useDraftEnvironmentRouting(input: DraftEnvironmentRoutingInput) } = input; const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); const getDraftSession = useComposerDraftStore((store) => store.getDraftSession); - const setLogicalProjectDraftThreadId = useComposerDraftStore( - (store) => store.setLogicalProjectDraftThreadId, - ); const allProjects = useProjects(); const { openScratchProject } = useScratchProject(); const needsLoadBalancing = automaticEnvironment && !draftThread?.loadBalancedEnvironmentId; @@ -118,10 +116,21 @@ export function useDraftEnvironmentRouting(input: DraftEnvironmentRoutingInput) (environment) => environment.environmentId === loadBalancing.environmentId, ); if (!target?.projectId) return; - setDraftThreadContext(draftId, { + retargetDraftToProject({ + draftId, projectRef: scopeProjectRef(target.environmentId, target.projectId), - environmentSelection: "auto", - loadBalancedEnvironmentId: target.environmentId, + project: + allProjects.find( + (project) => + project.environmentId === target.environmentId && project.id === target.projectId, + ) ?? null, + environmentSettings: environmentById.get(target.environmentId)?.serverConfig?.settings, + managedPolicy: environmentById.get(target.environmentId)?.serverConfig?.managedPolicy, + providers: providersByEnvironment.get(target.environmentId) ?? [], + threads: readThreadShells(), + logicalProjectKey: null, + routing: { environmentSelection: "auto", loadBalancedEnvironmentId: target.environmentId }, + refreshModel: false, }); }, [ needsLoadBalancing, @@ -129,8 +138,10 @@ export function useDraftEnvironmentRouting(input: DraftEnvironmentRoutingInput) loadBalancing.environmentId, draftId, logicalProjectEnvironments, + allProjects, + environmentById, + providersByEnvironment, sendInFlightRef, - setDraftThreadContext, ]); const onAutoEnvironment = () => { if (envLocked || !draftId) return; @@ -221,21 +232,20 @@ export function useDraftEnvironmentRouting(input: DraftEnvironmentRoutingInput) currentDraft.projectId !== originalDraft.projectId ) return; - const projectRef = scopeProjectRef(target.environmentId, project.id); - if (activeProjectIsScratch) { - setLogicalProjectDraftThreadId( - deriveLogicalProjectKeyFromSettings(project, projectGroupingSettings), - projectRef, - draftId, - { environmentSelection: "manual", loadBalancedEnvironmentId: null }, - ); - } else { - setDraftThreadContext(draftId, { - projectRef, - environmentSelection: "manual", - loadBalancedEnvironmentId: null, - }); - } + retargetDraftToProject({ + draftId, + projectRef: scopeProjectRef(target.environmentId, project.id), + project, + environmentSettings: environmentById.get(target.environmentId)?.serverConfig?.settings, + managedPolicy: environmentById.get(target.environmentId)?.serverConfig?.managedPolicy, + providers: providersByEnvironment.get(target.environmentId) ?? [], + threads: readThreadShells(), + logicalProjectKey: activeProjectIsScratch + ? deriveLogicalProjectKeyFromSettings(project, projectGroupingSettings) + : null, + routing: { environmentSelection: "manual", loadBalancedEnvironmentId: null }, + refreshModel: true, + }); }; const finish = () => { if (environmentChangeRef.current === request) environmentChangeRef.current = null; diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index 44215edfc5be7..5dc71b9a5ad93 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -29,10 +29,11 @@ import { useProjects, useQuietThreadShell, } from "../state/entities"; +import { resolveProjectLastUsedSettings } from "@t3tools/client-runtime/state/project-last-used-settings"; import { hasExplicitComposerModelSelection, resolveNewDraftStartFromOrigin, - resolveNewThreadModelSelectionOverride, + resolveNewThreadDefaults, } from "../lib/chatThreadActions"; import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; import { readT3ProjectFile } from "../lib/t3ProjectFileDefaults"; @@ -48,8 +49,6 @@ import { useAtomValue } from "@effect/atom-react"; import { defaultProjectFallbackReadyAtom } from "../state/projects"; import { useThreadRouteTarget } from "./useThreadRouteTarget"; import { readUnloadedThreadCount } from "../state/olderSettledShelf"; -import { resolveProjectDefaultProviderModelSelection } from "../providerInstances"; -import { resolveNewThreadModelSelection } from "@t3tools/shared/usageLimits"; export const LAST_USED_PROJECT_STORAGE_KEY = "t3code:last-used-project:v1"; export const lastUsedProjectKeySchema = Schema.NullOr(Schema.String); @@ -128,27 +127,6 @@ export function createNewThreadHandler(router: AnyRouter) { const requestingRouteHref = router.state.location.href; const routeChangedSinceRequest = () => router.state.location.href !== requestingRouteHref; const currentRouteTarget = getCurrentRouteTarget(); - const carrySourceShell = - currentRouteTarget?.kind === "server" ? readThreadShell(currentRouteTarget.threadRef) : null; - const carrySourceDraft = - currentRouteTarget?.kind === "draft" ? getDraftSession(currentRouteTarget.draftId) : null; - const carrySourceComposer = currentRouteTarget - ? getComposerDraft( - currentRouteTarget.kind === "server" - ? currentRouteTarget.threadRef - : currentRouteTarget.draftId, - ) - : null; - const composerActiveProvider = carrySourceComposer?.activeProvider ?? null; - const composerModelSelection = composerActiveProvider - ? (carrySourceComposer?.modelSelectionByProvider[composerActiveProvider] ?? null) - : null; - const carryModelSelection = composerModelSelection ?? carrySourceShell?.modelSelection ?? null; - const carryInteractionMode = - carrySourceComposer?.interactionMode ?? - carrySourceShell?.interactionMode ?? - carrySourceDraft?.interactionMode ?? - null; const project = projects.find( (candidate) => candidate.id === projectRef.projectId && @@ -161,34 +139,18 @@ export function createNewThreadHandler(router: AnyRouter) { project?.id ?? null, project, ); - const configuredDefaultModelSelection = projectSettings.settings.defaultModelSelection ?? null; - const fallbackResolution = resolveNewThreadModelSelection({ - defaultSelection: - configuredDefaultModelSelection ?? - (carryModelSelection || useComposerDraftStore.getState().stickyActiveProvider !== null - ? null - : resolveProjectDefaultProviderModelSelection( - environmentServerConfigs.get(projectRef.environmentId)?.providers ?? [], - targetServerSettings, - projectRef.projectId, - )), - fallbackSelection: targetServerSettings.fallbackModelSelection ?? null, - providers: environmentServerConfigs.get(projectRef.environmentId)?.providers ?? [], - settings: targetServerSettings, - projectId: projectRef.projectId, - now: Date.now(), - }); - const projectDefaultModelSelection = fallbackResolution.fallback - ? fallbackResolution.selection - : configuredDefaultModelSelection; - const defaultRuntimeMode = projectSettings.settings.defaultRuntimeMode; - const resolveModelSelectionOverride = (destinationDraftId: DraftId) => - resolveNewThreadModelSelectionOverride({ - projectDefaultSelection: projectDefaultModelSelection ?? null, - carrySelection: carryModelSelection, - carrySourceDraftId: - currentRouteTarget?.kind === "draft" ? currentRouteTarget.draftId : null, - destinationDraftId, + // A new thread starts from the target project, not from whatever thread + // happens to be on screen. + const { modelSelection: defaultModelSelection, runtimeMode: defaultRuntimeMode } = + resolveNewThreadDefaults({ + projectId: projectRef.projectId, + projectSettings, + lastUsed: resolveProjectLastUsedSettings(readThreadShells(), projectRef), + serverSettings: targetServerSettings, + managedPolicy: environmentServerConfigs.get(projectRef.environmentId)?.managedPolicy, + providers: environmentServerConfigs.get(projectRef.environmentId)?.providers ?? [], + hasStickyProvider: useComposerDraftStore.getState().stickyActiveProvider !== null, + now: Date.now(), }); const resolveDefaultEnvMode = async (): Promise => { // Environment queries wait for a connection, so reading the project file on an @@ -298,19 +260,15 @@ export function createNewThreadHandler(router: AnyRouter) { if (workspaceContext) { setDraftThreadContext(emptyStoredDraftThread.draftId, { ...workspaceContext, - ...(!isDraftAlreadyOpen ? { runtimeMode: defaultRuntimeMode } : {}), - ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}), + runtimeMode: defaultRuntimeMode, }); } const storedDraft = getComposerDraft(emptyStoredDraftThread.draftId); const storedDraftHasExplicitModelPick = hasExplicitComposerModelSelection(storedDraft); if (!storedDraftHasExplicitModelPick) { applyStickyState(emptyStoredDraftThread.draftId); - const modelSelectionOverride = resolveModelSelectionOverride( - emptyStoredDraftThread.draftId, - ); - if (modelSelectionOverride) { - setModelSelection(emptyStoredDraftThread.draftId, modelSelectionOverride, { + if (defaultModelSelection) { + setModelSelection(emptyStoredDraftThread.draftId, defaultModelSelection, { replaceOptions: true, }); } @@ -323,8 +281,7 @@ export function createNewThreadHandler(router: AnyRouter) { { threadId: emptyStoredDraftThread.threadId, ...workspaceContext, - ...(!isDraftAlreadyOpen ? { runtimeMode: defaultRuntimeMode } : {}), - ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}), + runtimeMode: defaultRuntimeMode, }, ); const opened = { @@ -420,12 +377,10 @@ export function createNewThreadHandler(router: AnyRouter) { newWorktreesStartFromOrigin: projectSettings.settings.newWorktreesStartFromOrigin, }), runtimeMode: defaultRuntimeMode, - ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}), }); applyStickyState(draftId); - const modelSelectionOverride = resolveModelSelectionOverride(draftId); - if (modelSelectionOverride) { - setModelSelection(draftId, modelSelectionOverride, { replaceOptions: true }); + if (defaultModelSelection) { + setModelSelection(draftId, defaultModelSelection, { replaceOptions: true }); } seedPrompt(draftId); await router.navigate({ diff --git a/apps/web/src/lib/chatThreadActions.test.ts b/apps/web/src/lib/chatThreadActions.test.ts index ad465cd06b2bf..fb2694eac9db6 100644 --- a/apps/web/src/lib/chatThreadActions.test.ts +++ b/apps/web/src/lib/chatThreadActions.test.ts @@ -1,36 +1,207 @@ -import { ProviderInstanceId, type ModelSelection } from "@t3tools/contracts"; -import { describe, expect, it } from "vite-plus/test"; -import { resolveNewThreadModelSelectionOverride } from "./chatThreadActions"; +import { + DEFAULT_SERVER_SETTINGS, + EnvironmentId, + ProjectId, + ProviderInstanceId, + ThreadId, + type ManagedPolicySummary, + type ModelSelection, + type ServerSettings, +} from "@t3tools/contracts"; +import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; +import { beforeEach, describe, expect, it } from "vite-plus/test"; +import { DraftId, useComposerDraftStore } from "../composerDraftStore"; +import { resolveNewThreadDefaults, retargetDraftToProject } from "./chatThreadActions"; -const PROJECT_DEFAULT_SELECTION: ModelSelection = { - instanceId: ProviderInstanceId.make("codex"), - model: "project-default", +const projectId = ProjectId.make("project-a"); +const ENVIRONMENT_DEFAULT: ModelSelection = { + instanceId: ProviderInstanceId.make("claudeAgent"), + model: "claude-opus-5-5", }; -const CARRIED_SELECTION: ModelSelection = { +const PROJECT_DEFAULT: ModelSelection = { instanceId: ProviderInstanceId.make("codex"), - model: "carried-model", + model: "gpt-6.1-mini", + options: [{ id: "reasoningEffort", value: "low" }], }; +const lastUsed = { + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-6.1-sol", + options: [{ id: "reasoningEffort", value: "high" }], + }, + runtimeMode: "auto", +} as const; + +function settingsFor(overrides: Partial = {}) { + return resolveProjectSettings( + { + ...DEFAULT_SERVER_SETTINGS, + defaultModelSelection: ENVIRONMENT_DEFAULT, + defaultRuntimeMode: "approval-required", + ...overrides, + }, + projectId, + ); +} + +const resolveDefaults = ( + projectSettings: ReturnType, + last: typeof lastUsed | null, + managedPolicy?: ManagedPolicySummary, +) => + resolveNewThreadDefaults({ + projectId, + projectSettings, + lastUsed: last, + serverSettings: undefined, + managedPolicy, + providers: [], + hasStickyProvider: false, + now: 0, + }); + +describe("resolveNewThreadDefaults", () => { + it("prefers the project's last-used settings over environment defaults", () => { + expect(resolveDefaults(settingsFor(), lastUsed)).toEqual({ + modelSelection: lastUsed.modelSelection, + runtimeMode: "auto", + }); + }); + + it("drops a last-used runtime mode above the policy ceiling for the environment default", () => { + const policy = { + organization: "Example", + lockedSettings: [], + limits: { maxRuntimeMode: "auto-accept-edits" }, + } as const; + expect(resolveDefaults(settingsFor(), lastUsed, policy).runtimeMode).toBe("approval-required"); + }); + + it("falls back to environment defaults for a project nobody has used", () => { + expect(resolveDefaults(settingsFor(), null)).toEqual({ + modelSelection: ENVIRONMENT_DEFAULT, + runtimeMode: "approval-required", + }); + }); + + it("lets settings configured on the project itself win over its last-used settings", () => { + const projectSettings = settingsFor({ + projectSettingsOverrides: { + [projectId]: { defaultModelSelection: PROJECT_DEFAULT, defaultRuntimeMode: "full-access" }, + }, + }); + + expect(resolveDefaults(projectSettings, lastUsed)).toEqual({ + modelSelection: PROJECT_DEFAULT, + runtimeMode: "full-access", + }); + }); +}); + +describe("retargetDraftToProject", () => { + const sourceRef = { environmentId: EnvironmentId.make("environment-a"), projectId }; + const destinationRef = { + environmentId: EnvironmentId.make("environment-b"), + projectId: ProjectId.make("project-b"), + }; + const draftId = DraftId.make("draft-retarget"); + const store = () => useComposerDraftStore.getState(); + const session = () => store().getDraftSession(draftId); + const composer = () => store().getComposerDraft(draftId); + const selection = (instance: string) => + composer()?.modelSelectionByProvider[ProviderInstanceId.make(instance)]; + const manualRouting = { + environmentSelection: "manual", + loadBalancedEnvironmentId: null, + } as const; + const autoRouting = { + environmentSelection: "auto", + loadBalancedEnvironmentId: destinationRef.environmentId, + } as const; + + beforeEach(() => { + useComposerDraftStore.setState({ + draftsByThreadKey: {}, + draftThreadsByThreadKey: {}, + logicalProjectDraftThreadKeyByLogicalProjectKey: {}, + stickyModelSelectionByProvider: {}, + stickyActiveProvider: null, + }); + // The draft opened in A inherited A's last-used settings into its session. + store().setProjectDraftThreadId(sourceRef, draftId, { + threadId: ThreadId.make("thread-retarget"), + runtimeMode: "full-access", + }); + store().setModelSelection(draftId, lastUsed.modelSelection, { replaceOptions: true }); + store().setPrompt(draftId, "keep this"); + }); + + const retarget = (overrides: Partial[0]> = {}) => + retargetDraftToProject({ + draftId, + projectRef: destinationRef, + project: null, + environmentSettings: { + ...DEFAULT_SERVER_SETTINGS, + defaultModelSelection: ENVIRONMENT_DEFAULT, + defaultRuntimeMode: "approval-required", + }, + managedPolicy: undefined, + providers: [], + threads: [], + logicalProjectKey: null, + refreshModel: true, + ...overrides, + }); + + it.each([ + ["a machine switch", { routing: manualRouting }], + ["automatic routing", { routing: autoRouting, refreshModel: false }], + ["a project pick", { logicalProjectKey: "project-b" }], + ])("drops the source's inherited runtime mode on %s to an unused project", (_, overrides) => { + retarget(overrides); + + expect(session()).toMatchObject({ + environmentId: destinationRef.environmentId, + projectId: destinationRef.projectId, + runtimeMode: "approval-required", + }); + expect(composer()?.prompt).toBe("keep this"); + }); + + it("takes the destination project's own history over its environment default", () => { + retarget({ + environmentSettings: { ...DEFAULT_SERVER_SETTINGS, defaultRuntimeMode: "full-access" }, + threads: [ + { + ...destinationRef, + modelSelection: PROJECT_DEFAULT, + runtimeMode: "approval-required", + updatedAt: "2026-10-21T10:00:00.000Z", + latestUserAuthoredMessageAt: "2026-10-21T10:00:00.000Z", + }, + ], + }); + + expect(session()).toMatchObject({ runtimeMode: "approval-required" }); + expect(selection("codex")).toEqual(PROJECT_DEFAULT); + }); + + it("leaves a runtime mode and model the user picked in the composer alone", () => { + store().setRuntimeMode(draftId, "full-access"); + store().setModelSelection(draftId, PROJECT_DEFAULT, { explicit: true }); + retarget(); + + expect(composer()).toMatchObject({ runtimeMode: "full-access" }); + expect(selection("codex")).toEqual(PROJECT_DEFAULT); + }); + + it("refreshes an inherited model on a manual switch but keeps it under automatic routing", () => { + retarget({ routing: autoRouting, refreshModel: false }); + expect(selection("codex")).toEqual(lastUsed.modelSelection); -describe("chatThreadActions", () => { - it("does not carry a non-explicit model from the destination draft back into itself", () => { - expect( - resolveNewThreadModelSelectionOverride({ - projectDefaultSelection: null, - carrySelection: CARRIED_SELECTION, - carrySourceDraftId: "draft-a", - destinationDraftId: "draft-a", - }), - ).toBeNull(); - }); - - it("keeps the project default above any carried selection", () => { - expect( - resolveNewThreadModelSelectionOverride({ - projectDefaultSelection: PROJECT_DEFAULT_SELECTION, - carrySelection: CARRIED_SELECTION, - carrySourceDraftId: "draft-a", - destinationDraftId: "draft-b", - }), - ).toEqual(PROJECT_DEFAULT_SELECTION); + retarget({ routing: manualRouting }); + expect(composer()?.activeProvider).toBe("claudeAgent"); + expect(selection("claudeAgent")).toEqual(ENVIRONMENT_DEFAULT); }); }); diff --git a/apps/web/src/lib/chatThreadActions.ts b/apps/web/src/lib/chatThreadActions.ts index ac5412c239753..78dc25e1d6b5f 100644 --- a/apps/web/src/lib/chatThreadActions.ts +++ b/apps/web/src/lib/chatThreadActions.ts @@ -1,11 +1,34 @@ import { scopeProjectRef } from "@t3tools/client-runtime/environment"; -import type { - EnvironmentId, - ModelSelection, - ProjectId, - ScopedProjectRef, +import { + resolveProjectLastUsedSettings, + type ProjectLastUsedSettings, + type ProjectLastUsedSettingsThread, +} from "@t3tools/client-runtime/state/project-last-used-settings"; +import { + DEFAULT_SERVER_SETTINGS, + managedRuntimeModeDenial, + type ManagedPolicySummary, + type EnvironmentId, + type ModelSelection, + type ProjectId, + type RuntimeMode, + type ScopedProjectRef, + type ServerProvider, + type ServerSettings, } from "@t3tools/contracts"; -import type { ComposerThreadDraftState, DraftThreadEnvMode } from "../composerDraftStore"; +import { resolveNewThreadModelSelection } from "@t3tools/shared/usageLimits"; +import { + resolveProjectSettings, + type LegacyProjectSettingsFields, + type ResolvedProjectSettings, +} from "@t3tools/shared/projectSettings"; +import { + useComposerDraftStore, + type ComposerThreadDraftState, + type DraftId, + type DraftThreadEnvMode, +} from "../composerDraftStore"; +import { resolveProjectDefaultProviderModelSelection } from "../providerInstances"; type ComposerModelSelectionState = Pick< ComposerThreadDraftState, @@ -43,16 +66,134 @@ export function resolveNewDraftStartFromOrigin(input: { return input.envMode === "worktree" && input.newWorktreesStartFromOrigin; } -export function resolveNewThreadModelSelectionOverride(input: { - readonly projectDefaultSelection: ModelSelection | null; - readonly carrySelection: ModelSelection | null; - readonly carrySourceDraftId: string | null; - readonly destinationDraftId: string; -}): ModelSelection | null { - return ( - input.projectDefaultSelection ?? - (input.carrySourceDraftId === input.destinationDraftId ? null : input.carrySelection) +/** + * A runtime mode configured on the project wins over the project's last-used one, + * which yields to the server's policy-clamped default when the policy denies it. + */ +export function resolveDefaultRuntimeMode( + projectSettings: ResolvedProjectSettings, + lastUsedRuntimeMode: RuntimeMode | null, + managedPolicy: ManagedPolicySummary | undefined, +): RuntimeMode { + const inherited = + projectSettings.sources.defaultRuntimeMode === "environment" ? lastUsedRuntimeMode : null; + return inherited !== null && managedRuntimeModeDenial(managedPolicy, inherited) === undefined + ? inherited + : projectSettings.settings.defaultRuntimeMode; +} + +/** + * What a new thread in a project starts with when the draft has no pick of its + * own: a setting configured on the project itself wins, then the project's + * last user-used settings, then environment defaults. Only defaults give way + * to the usage-limit fallback; a model the user last ran here is their pick. + */ +export function resolveNewThreadDefaults(input: { + readonly projectId: ProjectId; + readonly projectSettings: ResolvedProjectSettings; + readonly lastUsed: ProjectLastUsedSettings | null; + readonly serverSettings: ServerSettings | undefined; + readonly managedPolicy: ManagedPolicySummary | undefined; + readonly providers: ReadonlyArray; + readonly hasStickyProvider: boolean; + readonly now: number; +}): { + readonly modelSelection: ModelSelection | null; + readonly runtimeMode: RuntimeMode; +} { + const { settings, sources } = input.projectSettings; + const runtimeMode = resolveDefaultRuntimeMode( + input.projectSettings, + input.lastUsed?.runtimeMode ?? null, + input.managedPolicy, ); + const projectSelection = + sources.defaultModelSelection === "environment" ? null : settings.defaultModelSelection; + if (!projectSelection && input.lastUsed) { + return { modelSelection: input.lastUsed.modelSelection, runtimeMode }; + } + const defaultSelection = settings.defaultModelSelection ?? null; + const resolution = resolveNewThreadModelSelection({ + defaultSelection: + defaultSelection ?? + (input.hasStickyProvider || !input.serverSettings + ? null + : resolveProjectDefaultProviderModelSelection( + input.providers, + input.serverSettings, + input.projectId, + )), + fallbackSelection: input.serverSettings?.fallbackModelSelection ?? null, + providers: input.providers, + ...(input.serverSettings ? { settings: input.serverSettings } : {}), + projectId: input.projectId, + now: input.now, + }); + return { + modelSelection: resolution.fallback ? resolution.selection : defaultSelection, + runtimeMode, + }; +} + +/** + * Moves a draft that keeps its content to another project or machine. The + * session holds only defaults (the composer holds the user's picks and outranks + * it), so it takes the destination's defaults instead of keeping the source's. + */ +export function retargetDraftToProject(input: { + readonly draftId: DraftId; + readonly projectRef: ScopedProjectRef; + readonly project: LegacyProjectSettingsFields | null; + readonly environmentSettings: ServerSettings | undefined; + readonly managedPolicy: ManagedPolicySummary | undefined; + readonly providers: ReadonlyArray; + readonly threads: ReadonlyArray; + readonly logicalProjectKey: string | null; + readonly routing?: { + readonly environmentSelection: "auto" | "manual"; + readonly loadBalancedEnvironmentId: EnvironmentId | null; + }; + /** Automatic routing picked the machine for the current model, so it keeps that model. */ + readonly refreshModel: boolean; +}): void { + const store = useComposerDraftStore.getState(); + const defaults = resolveNewThreadDefaults({ + projectId: input.projectRef.projectId, + projectSettings: resolveProjectSettings( + input.environmentSettings ?? DEFAULT_SERVER_SETTINGS, + input.projectRef.projectId, + input.project, + ), + lastUsed: resolveProjectLastUsedSettings(input.threads, input.projectRef), + serverSettings: input.environmentSettings, + managedPolicy: input.managedPolicy, + providers: input.providers, + hasStickyProvider: store.stickyActiveProvider !== null, + now: Date.now(), + }); + const context = { + ...input.routing, + runtimeMode: defaults.runtimeMode, + }; + if (input.logicalProjectKey === null) { + store.setDraftThreadContext(input.draftId, { projectRef: input.projectRef, ...context }); + } else { + store.setLogicalProjectDraftThreadId( + input.logicalProjectKey, + input.projectRef, + input.draftId, + context, + ); + } + if ( + input.refreshModel && + !hasExplicitComposerModelSelection(store.getComposerDraft(input.draftId)) + ) { + store.applyStickyState(input.draftId); + if (defaults.modelSelection) { + store.setModelSelection(input.draftId, defaults.modelSelection, { replaceOptions: true }); + } + } } export function hasExplicitComposerModelSelection( diff --git a/apps/web/src/state/entities.ts b/apps/web/src/state/entities.ts index 19c0736779897..31efb29c2b577 100644 --- a/apps/web/src/state/entities.ts +++ b/apps/web/src/state/entities.ts @@ -17,7 +17,9 @@ import { parseThreadKey, threadKey, } from "@t3tools/client-runtime/state/entities"; -import type { ScopedProjectRef, ScopedThreadRef } from "@t3tools/contracts"; +import type { RuntimeMode, ScopedProjectRef, ScopedThreadRef } from "@t3tools/contracts"; +import { parseScopedProjectKey, scopedProjectKey } from "@t3tools/client-runtime/environment"; +import { resolveProjectLastUsedSettings } from "@t3tools/client-runtime/state/project-last-used-settings"; import type { EnvironmentId, OrchestrationV2ProjectedTurnItem, @@ -143,6 +145,26 @@ export function useThreadShellsForProjectRefs( return useAtomValue(environmentThreadShells.threadShellsForProjectRefsAtom(refs)); } +const EMPTY_RUNTIME_MODE_ATOM = Atom.make(null).pipe( + Atom.withLabel("web-project-last-used-runtime-mode:empty"), +); +// Quiet shells and a primitive result keep streaming ticks from re-rendering readers. +const projectLastUsedRuntimeModeAtom = Atom.family((key: string) => { + const ref = parseScopedProjectKey(key); + return Atom.make((get): RuntimeMode | null => + ref === null + ? null + : (resolveProjectLastUsedSettings(get(environmentThreadShells.quietThreadShellsAtom), ref) + ?.runtimeMode ?? null), + ).pipe(Atom.withLabel(`web-project-last-used-runtime-mode:${key}`)); +}); + +export function useProjectLastUsedRuntimeMode(ref: ScopedProjectRef | null): RuntimeMode | null { + return useAtomValue( + ref === null ? EMPTY_RUNTIME_MODE_ATOM : projectLastUsedRuntimeModeAtom(scopedProjectKey(ref)), + ); +} + export function useProject(ref: ScopedProjectRef | null): EnvironmentProject | null { return useAtomValue(ref === null ? EMPTY_PROJECT_ATOM : environmentProjects.projectAtom(ref)); } diff --git a/docs/user/composer.md b/docs/user/composer.md index df880e49c6ca5..92c68b5516cce 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -120,9 +120,10 @@ instance: ## Model defaults -T3 Code remembers your provider, model, and model options for new threads. A -project's configured model takes precedence; resetting that project setting -returns to the remembered selection. +T3 Code remembers the provider, model, and model options you last used in each +project for new threads. A project's configured model takes precedence; resetting +that project setting returns to its remembered selection. A project with no +previously used thread falls back to your general defaults. Leaving reasoning level or service tier unset uses the provider's own configuration. diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 09ce63e2a3e65..9baf072908dce 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -7,8 +7,8 @@ need a separate branch and working directory. On web and desktop, **New thread** opens the project picker when you have more than one open project in the default or project-grouped sidebar. Archived projects don't count. **Shift-click** to start in -the current project, or use a project's own new-thread action. A new thread carries -your model and mode selections, unless the destination project has its own model default. +the current project, or use a project's own new-thread action. A new thread starts with the +model and access mode you last used in that project, unless the project has its own defaults. Its branch and workspace mode come from your configured defaults. To continue in an existing worktree, use **New thread in this worktree** from the branch toolbar. diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index c9e1260b956f7..ffa708f8916d1 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -335,6 +335,10 @@ "types": "./src/state/subagentRuntime.ts", "default": "./src/state/subagentRuntime.ts" }, + "./state/project-last-used-settings": { + "types": "./src/state/projectLastUsedSettings.ts", + "default": "./src/state/projectLastUsedSettings.ts" + }, "./state/thread-sort": { "types": "./src/state/threadSort.ts", "default": "./src/state/threadSort.ts" diff --git a/packages/client-runtime/src/state/projectLastUsedSettings.test.ts b/packages/client-runtime/src/state/projectLastUsedSettings.test.ts new file mode 100644 index 0000000000000..bb288dcd84508 --- /dev/null +++ b/packages/client-runtime/src/state/projectLastUsedSettings.test.ts @@ -0,0 +1,97 @@ +import { EnvironmentId, ProjectId, ProviderInstanceId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + resolveProjectLastUsedSettings, + type ProjectLastUsedSettingsThread, +} from "./projectLastUsedSettings.ts"; + +const environmentId = EnvironmentId.make("environment-a"); +const projectId = ProjectId.make("project-a"); + +function thread( + model: string, + overrides: Partial = {}, +): ProjectLastUsedSettingsThread { + return { + environmentId, + projectId, + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model }, + runtimeMode: "full-access", + updatedAt: "2026-10-01T00:00:00.000Z", + ...overrides, + }; +} + +describe("resolveProjectLastUsedSettings", () => { + it("uses the project thread the user last wrote in", () => { + const settings = resolveProjectLastUsedSettings( + [ + thread("renamed-recently", { + updatedAt: "2026-10-05T00:00:00.000Z", + latestUserAuthoredMessageAt: "2026-10-02T00:00:00.000Z", + }), + thread("written-recently", { + runtimeMode: "auto", + latestUserAuthoredMessageAt: "2026-10-03T00:00:00.000Z", + }), + ], + { environmentId, projectId }, + ); + + expect(settings).toEqual({ + modelSelection: { instanceId: "codex", model: "written-recently" }, + runtimeMode: "auto", + }); + }); + + it("skips threads the user never wrote in, however recent", () => { + const settings = resolveProjectLastUsedSettings( + [ + thread("user-written", { + runtimeMode: "auto", + latestUserAuthoredMessageAt: "2026-10-02T00:00:00.000Z", + }), + thread("scheduled-run", { + updatedAt: "2026-10-09T00:00:00.000Z", + latestUserAuthoredMessageAt: null, + }), + thread("legacy-recent", { updatedAt: "2026-10-08T00:00:00.000Z" }), + ], + { environmentId, projectId }, + ); + + expect(settings?.modelSelection.model).toBe("user-written"); + expect( + resolveProjectLastUsedSettings([thread("automated", { latestUserAuthoredMessageAt: null })], { + environmentId, + projectId, + }), + ).toBeNull(); + }); + + it("falls back to the latest update on servers without authored timestamps", () => { + const settings = resolveProjectLastUsedSettings( + [thread("older"), thread("newer", { updatedAt: "2026-10-03T00:00:00.000Z" })], + { environmentId, projectId }, + ); + + expect(settings?.modelSelection.model).toBe("newer"); + }); + + it("ignores other projects, other environments, and subagent threads", () => { + const later = { updatedAt: "2026-10-09T00:00:00.000Z" }; + const settings = resolveProjectLastUsedSettings( + [ + thread("own"), + thread("other-project", { ...later, projectId: ProjectId.make("project-b") }), + thread("other-environment", { ...later, environmentId: EnvironmentId.make("env-b") }), + thread("subagent", { ...later, lineage: { relationshipToParent: "subagent" } }), + ], + { environmentId, projectId }, + ); + + expect(settings?.modelSelection.model).toBe("own"); + expect(resolveProjectLastUsedSettings([], { environmentId, projectId })).toBeNull(); + }); +}); diff --git a/packages/client-runtime/src/state/projectLastUsedSettings.ts b/packages/client-runtime/src/state/projectLastUsedSettings.ts new file mode 100644 index 0000000000000..b60b7b1bdd117 --- /dev/null +++ b/packages/client-runtime/src/state/projectLastUsedSettings.ts @@ -0,0 +1,56 @@ +import type { EnvironmentId, ModelSelection, ProjectId, RuntimeMode } from "@t3tools/contracts"; +import type { EnvironmentThreadShell } from "./models.ts"; + +export type ProjectLastUsedSettingsThread = Pick< + EnvironmentThreadShell, + "environmentId" | "projectId" | "modelSelection" | "runtimeMode" | "updatedAt" +> & { + readonly latestUserAuthoredMessageAt?: string | null; + readonly lineage?: Pick; +}; + +export interface ProjectLastUsedSettings { + readonly modelSelection: ModelSelection; + readonly runtimeMode: RuntimeMode; +} + +/** + * Settings of the project thread the user last wrote in. Subagent threads and a null + * latestUserAuthoredMessageAt (automation only) say nothing about the user's choice. + * Older servers omit the field; those threads rank by updatedAt, below any that carry it. + */ +export function resolveProjectLastUsedSettings( + threads: ReadonlyArray, + project: { readonly environmentId: EnvironmentId; readonly projectId: ProjectId }, +): ProjectLastUsedSettings | null { + let authored: ProjectLastUsedSettingsThread | null = null; + let authoredAt = ""; + let legacy: ProjectLastUsedSettingsThread | null = null; + let legacyAt = ""; + for (const thread of threads) { + if ( + thread.environmentId !== project.environmentId || + thread.projectId !== project.projectId || + thread.lineage?.relationshipToParent === "subagent" || + thread.latestUserAuthoredMessageAt === null + ) { + continue; + } + if (thread.latestUserAuthoredMessageAt === undefined) { + if (legacy === null || thread.updatedAt > legacyAt) { + legacy = thread; + legacyAt = thread.updatedAt; + } + } else if (authored === null || thread.latestUserAuthoredMessageAt > authoredAt) { + authored = thread; + authoredAt = thread.latestUserAuthoredMessageAt; + } + } + const latest = authored ?? legacy; + return latest === null + ? null + : { + modelSelection: latest.modelSelection, + runtimeMode: latest.runtimeMode, + }; +}