Skip to content
Merged
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
4 changes: 1 addition & 3 deletions apps/server/src/orchestration/decider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
Expand Down
13 changes: 4 additions & 9 deletions apps/server/src/sourceControl/GitHubAccountResolver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [],
}),
),
Expand All @@ -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(
Expand Down
5 changes: 1 addition & 4 deletions apps/server/src/sourceControl/GitHubAccountResolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,10 +130,7 @@ export const make = Effect.gen(function* () {
Effect.catch(() => Effect.succeed(null)),
);

const resolveToken = (
cwd: string,
account: GitHubAccountRef,
): Effect.Effect<string | null> =>
const resolveToken = (cwd: string, account: GitHubAccountRef): Effect.Effect<string | null> =>
Effect.gen(function* () {
const now = yield* Clock.currentTimeMillis;
const cache = yield* Ref.get(tokenCache);
Expand Down
199 changes: 199 additions & 0 deletions apps/web/src/components/ProjectDefaultAgentField.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="grid gap-4 sm:grid-cols-2 sm:gap-3">
<label className="grid min-w-0 gap-1.5" htmlFor={`${idPrefix}-account`}>
<span className="font-medium text-foreground">Default agent</span>
<Select value={accountSelectValue} onValueChange={handleAccountChange} disabled={disabled}>
<SelectTrigger id={`${idPrefix}-account`} className="w-full sm:min-h-7.5">
<SelectValue>
{selectedEntry ? (
<span className="flex min-w-0 items-center gap-2">
<ProviderInstanceIcon
driverKind={selectedEntry.driverKind}
displayName={selectedEntry.displayName}
accentColor={selectedEntry.accentColor}
className="size-4"
iconClassName="size-4"
/>
<span className="min-w-0 truncate">{selectedEntry.displayName}</span>
</span>
) : showStalePin ? (
<span className="min-w-0 truncate text-muted-foreground">
{selectedInstanceId} (unavailable)
</span>
) : (
"Use default account"
)}
</SelectValue>
</SelectTrigger>
<SelectPopup align="start" alignItemWithTrigger={false}>
<SelectItem hideIndicator value={USE_DEFAULT_VALUE}>
Use default account
</SelectItem>
{entries.map((entry) => (
<SelectItem hideIndicator key={entry.instanceId} value={entry.instanceId}>
<span className="flex min-w-0 items-center gap-2">
<ProviderInstanceIcon
driverKind={entry.driverKind}
displayName={entry.displayName}
accentColor={entry.accentColor}
className="size-4"
iconClassName="size-4"
/>
<span className="min-w-0 truncate">{entry.displayName}</span>
</span>
</SelectItem>
))}
{showStalePin ? (
<SelectItem hideIndicator value={selectedInstanceId}>
<span className="min-w-0 truncate text-muted-foreground">
{selectedInstanceId} (unavailable)
</span>
</SelectItem>
) : null}
</SelectPopup>
</Select>
</label>
<label className="grid min-w-0 gap-1.5" htmlFor={`${idPrefix}-model`}>
<span className="font-medium text-foreground">Model</span>
<Select
value={value?.model ?? ""}
onValueChange={handleModelChange}
disabled={disabled || !selectedEntry || models.length === 0}
>
<SelectTrigger id={`${idPrefix}-model`} className="w-full sm:min-h-7.5">
<SelectValue>
{selectedEntry
? (models.find((model) => model.slug === value?.model)?.name ??
value?.model ??
"Default model")
: "—"}
</SelectValue>
</SelectTrigger>
<SelectPopup align="start" alignItemWithTrigger={false}>
{models.map((model) => (
<SelectItem hideIndicator key={model.slug} value={model.slug}>
{model.name}
</SelectItem>
))}
</SelectPopup>
</Select>
</label>
</div>
);
}
32 changes: 32 additions & 0 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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,
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -2394,6 +2416,16 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
Environment: {projectRenameTarget.environmentLabel}
</p>
) : null}
{projectRenameTarget ? (
<ProjectDefaultAgentField
idPrefix={`project-agent-${projectRenameTarget.physicalProjectKey}`}
environmentId={projectRenameTarget.environmentId}
projectId={projectRenameTarget.id}
onChange={(selection) => {
void updateProjectDefaultModelSelection(projectRenameTarget, selection);
}}
/>
) : null}
</DialogPanel>
<DialogFooter>
<Button variant="outline" onClick={closeProjectRenameDialog}>
Expand Down
40 changes: 32 additions & 8 deletions apps/web/src/components/SidebarV2.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
} from "@t3tools/client-runtime/environment";
import type {
GitHubAccountRef,
ModelSelection,
ScopedThreadRef,
SidebarProjectGroupingMode,
} from "@t3tools/contracts";
Expand Down Expand Up @@ -137,6 +138,7 @@ import {
type SnoozePreset,
} from "./Sidebar.snooze";
import { GitHubIcon } from "./Icons";
import { ProjectDefaultAgentField } from "./ProjectDefaultAgentField";
import { ProjectFavicon } from "./ProjectFavicon";
import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon";
import { getTriggerDisplayModelLabel } from "./chat/providerIconUtils";
Expand Down Expand Up @@ -1022,10 +1024,7 @@ function githubAccountValue(account: { readonly host: string; readonly login: st

// github.com is the overwhelmingly common host, so hide it to keep the label
// short; surface the host only for GitHub Enterprise / self-hosted accounts.
function githubAccountLabel(account: {
readonly host: string;
readonly login: string;
}): string {
function githubAccountLabel(account: { readonly host: string; readonly login: string }): string {
return account.host === "github.com" ? account.login : `${account.login} · ${account.host}`;
}

Expand All @@ -1038,10 +1037,7 @@ function ProjectGitHubAccountField({
onSelect,
}: {
readonly member: SidebarProjectGroupMember;
readonly onSelect: (
member: SidebarProjectGroupMember,
account: GitHubAccountRef | null,
) => void;
readonly onSelect: (member: SidebarProjectGroupMember, account: GitHubAccountRef | null) => void;
}) {
const discovery = useEnvironmentQuery(
sourceControlEnvironment.discovery({ environmentId: member.environmentId, input: {} }),
Expand Down Expand Up @@ -1469,6 +1465,26 @@ export default function SidebarV2() {
[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 updateProjectGitHubAccount = useCallback(
async (member: SidebarProjectGroupMember, account: GitHubAccountRef | null) => {
const current = member.gitHubAccount;
Expand Down Expand Up @@ -2843,6 +2859,14 @@ export default function SidebarV2() {
onSelect={updateProjectGitHubAccount}
/>
</div>
<ProjectDefaultAgentField
idPrefix={`project-agent-${member.physicalProjectKey}`}
environmentId={member.environmentId}
projectId={member.id}
onChange={(selection) => {
void updateProjectDefaultModelSelection(member, selection);
}}
/>
{projectActionsTarget.memberProjects.length > 1 ? (
<div className="flex justify-end">
<Button
Expand Down
Loading