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}