diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts
index 887f14591d30..16d02496d65f 100644
--- a/apps/server/src/orchestration/decider.ts
+++ b/apps/server/src/orchestration/decider.ts
@@ -288,9 +288,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
...(command.defaultModelSelection !== undefined
? { defaultModelSelection: command.defaultModelSelection }
: {}),
- ...(command.gitHubAccount !== undefined
- ? { gitHubAccount: command.gitHubAccount }
- : {}),
+ ...(command.gitHubAccount !== undefined ? { gitHubAccount: command.gitHubAccount } : {}),
...(command.scripts !== undefined ? { scripts: command.scripts } : {}),
updatedAt: occurredAt,
},
diff --git a/apps/server/src/sourceControl/GitHubAccountResolver.test.ts b/apps/server/src/sourceControl/GitHubAccountResolver.test.ts
index 19c1d5b2a4d5..ff30890bcdb4 100644
--- a/apps/server/src/sourceControl/GitHubAccountResolver.test.ts
+++ b/apps/server/src/sourceControl/GitHubAccountResolver.test.ts
@@ -119,7 +119,9 @@ describe("GitHubAccountResolver.resolveForCwd", () => {
Effect.provide(
layerFor(
shellSnapshot({
- projects: [projectShell({ id: "app", workspaceRoot: "/repos/app", gitHubAccount: null })],
+ projects: [
+ projectShell({ id: "app", workspaceRoot: "/repos/app", gitHubAccount: null }),
+ ],
threads: [],
}),
),
@@ -137,14 +139,7 @@ describe("GitHubAccountResolver.resolveForCwd", () => {
token: "gho_secret",
});
const call = mockRun.mock.calls[0]?.[0];
- assert.deepEqual(call?.args, [
- "auth",
- "token",
- "--user",
- "octo",
- "--hostname",
- "github.com",
- ]);
+ assert.deepEqual(call?.args, ["auth", "token", "--user", "octo", "--hostname", "github.com"]);
}).pipe(
Effect.provide(
layerFor(
diff --git a/apps/server/src/sourceControl/GitHubAccountResolver.ts b/apps/server/src/sourceControl/GitHubAccountResolver.ts
index 0be91d3cea22..16dfe50e66ca 100644
--- a/apps/server/src/sourceControl/GitHubAccountResolver.ts
+++ b/apps/server/src/sourceControl/GitHubAccountResolver.ts
@@ -130,10 +130,7 @@ export const make = Effect.gen(function* () {
Effect.catch(() => Effect.succeed(null)),
);
- const resolveToken = (
- cwd: string,
- account: GitHubAccountRef,
- ): Effect.Effect =>
+ const resolveToken = (cwd: string, account: GitHubAccountRef): Effect.Effect =>
Effect.gen(function* () {
const now = yield* Clock.currentTimeMillis;
const cache = yield* Ref.get(tokenCache);
diff --git a/apps/web/src/components/ProjectDefaultAgentField.tsx b/apps/web/src/components/ProjectDefaultAgentField.tsx
new file mode 100644
index 000000000000..f4464c220ac6
--- /dev/null
+++ b/apps/web/src/components/ProjectDefaultAgentField.tsx
@@ -0,0 +1,199 @@
+import { useMemo } from "react";
+
+import { useAtomValue } from "@effect/atom-react";
+import { scopeProjectRef } from "@t3tools/client-runtime/environment";
+import type {
+ EnvironmentId,
+ ModelSelection,
+ ProjectId,
+ ProviderInstanceId,
+} from "@t3tools/contracts";
+
+import {
+ applyProviderInstanceSettings,
+ deriveProviderInstanceEntries,
+ getDefaultProviderInstanceModel,
+ isProviderInstancePickerVisible,
+ sortProviderInstanceEntries,
+ type ProviderInstanceEntry,
+} from "../providerInstances";
+import { useProject } from "../state/entities";
+import { environmentServerConfigsAtom } from "../state/server";
+import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon";
+import {
+ Select,
+ SelectItem,
+ SelectPopup,
+ SelectTrigger,
+ SelectValue,
+} from "./ui/select";
+
+// Sentinel routing value for "no pinned account": the composer falls back to
+// the first available provider instance for the environment. Kept out of the
+// `ProviderInstanceId` space so it can never collide with a real instance id.
+const USE_DEFAULT_VALUE = "__t3_project_default__";
+
+/**
+ * Per-project "default agent" control. Picks which configured provider
+ * instance (i.e. which account/subscription — e.g. two separate Claude
+ * logins) new threads in a project start on, plus that instance's model.
+ *
+ * The selection is written to the project's `defaultModelSelection`; the chat
+ * composer already resolves a new thread's provider from the project default
+ * when the draft doesn't override it, so pinning an account here routes every
+ * fresh thread in the project to that account's credentials.
+ *
+ * Provider instances are read per-environment: a project's account choices are
+ * exactly the accounts configured on the server that hosts it.
+ */
+export function ProjectDefaultAgentField(props: {
+ environmentId: EnvironmentId;
+ projectId: ProjectId;
+ onChange: (selection: ModelSelection | null) => void;
+ disabled?: boolean;
+ idPrefix: string;
+}) {
+ const { environmentId, projectId, onChange, disabled, idPrefix } = props;
+ const projectRef = useMemo(
+ () => scopeProjectRef(environmentId, projectId),
+ [environmentId, projectId],
+ );
+ // Read the selection live so the control reflects the persisted value even
+ // when the enclosing dialog renders from a captured project snapshot.
+ const value = useProject(projectRef)?.defaultModelSelection ?? null;
+ const serverConfigs = useAtomValue(environmentServerConfigsAtom);
+ const config = serverConfigs.get(environmentId) ?? null;
+
+ const entries = useMemo(() => {
+ const providers = config?.providers ?? [];
+ const settings = config?.settings;
+ const derived = deriveProviderInstanceEntries(providers);
+ const withSettings = settings
+ ? applyProviderInstanceSettings(derived, settings)
+ : derived;
+ return sortProviderInstanceEntries(withSettings).filter(isProviderInstancePickerVisible);
+ }, [config?.providers, config?.settings]);
+
+ const providers = config?.providers ?? [];
+
+ const selectedInstanceId = value?.instanceId ?? null;
+ const selectedEntry: ProviderInstanceEntry | undefined = selectedInstanceId
+ ? entries.find((entry) => entry.instanceId === selectedInstanceId)
+ : undefined;
+
+ // A pinned instance that has since been removed/disabled still shows so the
+ // user can see (and clear) the stale pin instead of it silently vanishing.
+ const showStalePin = selectedInstanceId !== null && selectedEntry === undefined;
+
+ const handleAccountChange = (next: string | null) => {
+ if (next === null || next === USE_DEFAULT_VALUE) {
+ onChange(null);
+ return;
+ }
+ const instanceId = next as ProviderInstanceId;
+ // Keep the model when re-selecting the same instance; otherwise reset to
+ // that instance's default so we never persist a cross-account model pair.
+ const model =
+ value?.instanceId === instanceId
+ ? value.model
+ : getDefaultProviderInstanceModel(providers, instanceId);
+ if (!model) {
+ // Instance reports no models yet (probe pending); pinning it now would
+ // produce an invalid selection, so leave the project on its default.
+ onChange(null);
+ return;
+ }
+ onChange({ instanceId, model });
+ };
+
+ const handleModelChange = (slug: string | null) => {
+ if (!selectedInstanceId || slug === null) return;
+ onChange({ instanceId: selectedInstanceId, model: slug });
+ };
+
+ const models = selectedEntry?.models ?? [];
+ const accountSelectValue = selectedInstanceId ?? USE_DEFAULT_VALUE;
+
+ return (
+
+
+
+
+ );
+}
diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx
index 98b5dcf84edb..58ca61720848 100644
--- a/apps/web/src/components/Sidebar.tsx
+++ b/apps/web/src/components/Sidebar.tsx
@@ -21,6 +21,7 @@ import {
ThreadStatusLabel,
ThreadWorktreeIndicator,
} from "./ThreadStatusIndicators";
+import { ProjectDefaultAgentField } from "./ProjectDefaultAgentField";
import { ProjectFavicon } from "./ProjectFavicon";
import { useAtomValue } from "@effect/atom-react";
import { autoAnimate } from "@formkit/auto-animate";
@@ -43,6 +44,7 @@ import { restrictToFirstScrollableAncestor, restrictToVerticalAxis } from "@dnd-
import { CSS } from "@dnd-kit/utilities";
import {
type ContextMenuItem,
+ type ModelSelection,
ProjectId,
type ScopedThreadRef,
type ResolvedKeybindingsConfig,
@@ -2068,6 +2070,26 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
}
}, [closeProjectRenameDialog, projectRenameTarget, projectRenameTitle, updateProject]);
+ const updateProjectDefaultModelSelection = useCallback(
+ async (member: SidebarProjectGroupMember, selection: ModelSelection | null) => {
+ const result = await updateProject({
+ environmentId: member.environmentId,
+ input: { projectId: member.id, defaultModelSelection: selection },
+ });
+ if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
+ const error = squashAtomCommandFailure(result);
+ toastManager.add(
+ stackedThreadToast({
+ type: "error",
+ title: "Failed to update project agent",
+ description: error instanceof Error ? error.message : "An error occurred.",
+ }),
+ );
+ }
+ },
+ [updateProject],
+ );
+
const closeProjectGroupingDialog = useCallback(() => {
setProjectGroupingTarget(null);
setProjectGroupingSelection("inherit");
@@ -2394,6 +2416,16 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
Environment: {projectRenameTarget.environmentLabel}
) : null}
+ {projectRenameTarget ? (
+ {
+ void updateProjectDefaultModelSelection(projectRenameTarget, selection);
+ }}
+ />
+ ) : null}