1import {
2 DEFAULT_SERVER_SETTINGS,
3 type ModelSelection,
4 type ProviderInstanceId,
5 type WorktreeSubmodules,
6} from "@t3tools/contracts";
7import { createModelSelection } from "@t3tools/shared/model";
8import { resolveProjectSettings } from "@t3tools/shared/projectSettings";
9import { useNavigate } from "@tanstack/react-router";
10
11import { mergeEnvironmentSettings } from "../../hooks/useSettings";
12import { getCustomModelOptionsByInstance } from "../../modelSelection";
13import {
14 applyProviderInstanceSettings,
15 deriveProviderInstanceEntries,
16 resolveDefaultProviderModelSelection,
17 sortProviderInstanceEntries,
18} from "../../providerInstances";
19import { useEnvironments } from "../../state/environments";
20import { EMPTY_SERVER_PROVIDERS } from "../../state/server";
21import { resolveEnvModeLabel, WORKTREE_SUBMODULES_LABELS } from "../BranchToolbar.logic";
22import { ProviderModelPicker } from "../chat/ProviderModelPicker";
23import { runtimeModeConfig, runtimeModeOptions } from "../chat/runtimeModeConfig";
24import { PULL_REQUEST_MERGE_METHOD_LABELS } from "../pullRequest/pullRequestDetail.logic";
25import { TraitsPicker } from "../chat/TraitsPicker";
26import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
27import { toastManager } from "../ui/toast";
28import { Switch } from "../ui/switch";
29import type { ProjectSettingsCategory } from "./ProjectSettingsPanel";
30import { searchableSetting } from "./settingsSearch";
31import { useSettingsScope } from "./SettingsScopeContext";
32import {
33 SETTINGS_PICKER_TRIGGER_CLASSNAME,
34 SettingResetButton,
35 SettingsRow,
36 SettingsSection,
37} from "./settingsLayout";
38import {
39 useScopedSettings,
40 useScopedSettingsMixed,
41 useScopedSettingSource,
42 useUpdateScopedSettings,
43} from "./useScopedSettings";
44
45/**
46 * Rows for the settings a project may override. The same rows edit
47 * environment defaults at an environment scope and project overrides at a
48 * project or checkout scope; the scoped hooks route the write.
49 */
50const WORKTREE_SUBMODULES_OPTIONS = ["recursive", "top-level", "none"] as const;
51function isWorktreeSubmodules(value: string | null): value is WorktreeSubmodules {
52 return value !== null && (WORKTREE_SUBMODULES_OPTIONS as readonly string[]).includes(value);
53}
54
55export function ProjectDefaultsSettings({ category }: { category: ProjectSettingsCategory }) {
56 const { scope, target, targets, connectedEnvironments } = useSettingsScope();
57 const settings = useScopedSettings();
58 const updateSettings = useUpdateScopedSettings();
59 const navigate = useNavigate();
60 const { environments } = useEnvironments();
61 const representative = target
62 ? environments.find((environment) => environment.environmentId === target.environmentId)
63 : undefined;
64 const providers = representative?.serverConfig?.providers ?? EMPTY_SERVER_PROVIDERS;
65 const selection = resolveDefaultProviderModelSelection(providers, settings.defaultModelSelection);
66 const entries = sortProviderInstanceEntries(
67 applyProviderInstanceSettings(
68 deriveProviderInstanceEntries(providers),
69 settings,
70 target?.projectId ?? null,
71 ),
72 );
73 const modelOptions = getCustomModelOptionsByInstance(
74 settings,
75 providers,
76 selection?.instanceId,
77 selection?.model,
78 );
79 const activeEntry = entries.find((entry) => entry.instanceId === selection?.instanceId);
80 const mixedModel = useScopedSettingsMixed(["defaultModelSelection"]);
81 const mixedPermissions = useScopedSettingsMixed(["defaultRuntimeMode"]);
82 const PermissionIcon = runtimeModeConfig[settings.defaultRuntimeMode].icon;
83 const mixedWorkspace = useScopedSettingsMixed(["defaultThreadEnvMode"]);
84 const mixedSubmodules = useScopedSettingsMixed(["worktreeSubmodules"]);
85 const mixedBrowser = useScopedSettingsMixed(["enableAgentBrowserAccess"]);
86 const mixedAutoPull = useScopedSettingsMixed(["defaultAutoPull"]);
87 const mixedAgentCredits = useScopedSettingsMixed(["removeAgentCreditsOnMerge"]);
88 const mixedMergeMethod = useScopedSettingsMixed(["pullRequestMergeMethod"]);
89 const modelSource = useScopedSettingSource(["defaultModelSelection"]);
90 const isProjectScope = scope.kind === "project" || scope.kind === "checkout";
91 const unavailable = connectedEnvironments.length === 0;
92 // File-backed keys show their effective value; the target already carries
93 // the checkout's t3.json, and a null file here only fills the built-in.
94 // The reset arrow beside the title clears the tier (SettingsRow handles a
95 // project override, the environment value is cleared here), so the picker
96 // has no "inherit" item.
97 const effective = target
98 ? resolveProjectSettings(target.settings, null, null, null).settings
99 : null;
100
101 function modelDisabledReason(instanceId: ProviderInstanceId, model: string): string | null {
102 const sourceEntry = entries.find((entry) => entry.instanceId === instanceId);
103 for (const candidate of targets) {
104 const environment = environments.find(
105 (entry) => entry.environmentId === candidate.environmentId,
106 );
107 const config = environment?.serverConfig;
108 if (!config) continue;
109 const entry = applyProviderInstanceSettings(
110 deriveProviderInstanceEntries(config.providers),
111 candidate.settings,
112 candidate.projectId,
113 ).find((option) => option.instanceId === instanceId);
114 const options = getCustomModelOptionsByInstance(
115 mergeEnvironmentSettings(candidate.settings, settings),
116 config.providers,
117 ).get(instanceId);
118 if (
119 !entry?.enabled ||
120 !entry.isAvailable ||
121 entry.driverKind !== sourceEntry?.driverKind ||
122 !options?.some((option) => option.slug === model && !option.isUnavailable)
123 ) {
124 return `This model is unavailable on ${environment?.label ?? "a selected environment"}. Select that environment to choose its model separately.`;
125 }
126 }
127 return null;
128 }
129
130 const setModel = (value: ModelSelection | null) => {
131 const reason = value ? modelDisabledReason(value.instanceId, value.model) : null;
132 if (reason) {
133 toastManager.add({ type: "error", title: "Default model not saved", description: reason });
134 return;
135 }
136 updateSettings({ defaultModelSelection: value });
137 };
138
139 const modelRow = (
140 <SettingsRow
141 serverScoped
142 settingKeys={["defaultModelSelection"]}
143 mixed={mixedModel}
144 id="default-model"
145 title="Model"
146 description={
147 isProjectScope
148 ? "Model for new threads in this project."
149 : "Default model for new threads. Projects can override it."
150 }
151 status={
152 unavailable || mixedModel || modelSource === "project"
153 ? undefined
154 : settings.defaultModelSelection === null
155 ? "Automatic"
156 : undefined
157 }
158 resetAction={
159 settings.defaultModelSelection !== null ? (
160 <SettingResetButton label="default model" onClick={() => setModel(null)} />
161 ) : null
162 }
163 control={
164 selection && activeEntry ? (
165 <div className="flex min-w-0 flex-wrap items-center justify-end gap-1.5">
166 <ProviderModelPicker
167 environmentId={representative?.environmentId ?? null}
168 activeInstanceId={selection.instanceId}
169 model={selection.model}
170 lockedProvider={null}
171 instanceEntries={entries}
172 modelOptionsByInstance={modelOptions}
173 triggerClassName={SETTINGS_PICKER_TRIGGER_CLASSNAME}
174 {...(mixedModel ? { triggerLabel: "Mixed" } : {})}
175 getModelDisabledReason={modelDisabledReason}
176 onOpenProviderSetup={(instanceId) => {
177 if (representative)
178 void navigate({
179 to: "/settings/providers",
180 search: { environmentId: representative.environmentId, instanceId },
181 });
182 }}
183 onInstanceModelChange={(instanceId, model) =>
184 setModel(createModelSelection(instanceId, model))
185 }
186 />
187 {!mixedModel ? (
188 <TraitsPicker
189 provider={activeEntry.driverKind}
190 models={activeEntry.models}
191 model={selection.model}
192 prompt=""
193 onPromptChange={() => {}}
194 modelOptions={selection.options ?? []}
195 allowPromptInjectedEffort={false}
196 planModeEnabled={settings.planModeEnabled}
197 triggerClassName={SETTINGS_PICKER_TRIGGER_CLASSNAME}
198 onModelOptionsChange={(options) =>
199 setModel(createModelSelection(selection.instanceId, selection.model, options))
200 }
201 />
202 ) : null}
203 </div>
204 ) : (
205 <span className="text-sm text-muted-foreground">No providers available</span>
206 )
207 }
208 />
209 );
210 const workspaceRow = (
211 <SettingsRow
212 serverScoped
213 settingKeys={["defaultThreadEnvMode"]}
214 mixed={mixedWorkspace}
215 id={searchableSetting("new-threads").id}
216 title="Workspace"
217 description={
218 isProjectScope
219 ? "Where new threads in this project start."
220 : "Where new threads start. Projects and their t3.json can override it."
221 }
222 resetAction={
223 !isProjectScope && settings.defaultThreadEnvMode !== null ? (
224 <SettingResetButton
225 label="default workspace"
226 onClick={() => updateSettings({ defaultThreadEnvMode: null })}
227 />
228 ) : null
229 }
230 control={
231 <Select
232 value={mixedWorkspace ? null : (effective?.defaultThreadEnvMode ?? null)}
233 onValueChange={(value) => {
234 if (value === "local" || value === "worktree")
235 updateSettings({ defaultThreadEnvMode: value });
236 }}
237 >
238 <SelectTrigger size="sm" aria-label="Default workspace">
239 <SelectValue>
240 {(value: string | null) =>
241 value === "local" || value === "worktree"
242 ? resolveEnvModeLabel(value)
243 : unavailable
244 ? "Unavailable"
245 : "Mixed"
246 }
247 </SelectValue>
248 </SelectTrigger>
249 <SelectPopup align="end" alignItemWithTrigger={false}>
250 <SelectItem value="local">{resolveEnvModeLabel("local")}</SelectItem>
251 <SelectItem value="worktree">{resolveEnvModeLabel("worktree")}</SelectItem>
252 </SelectPopup>
253 </Select>
254 }
255 />
256 );
257
258 return (
259 <SettingsSection
260 id={
261 category === "general" || category === "project"
262 ? "project-defaults"
263 : category === "integrations"
264 ? "browser-access"
265 : "source-control-defaults"
266 }
267 title={
268 category === "general" || category === "project"
269 ? "New threads"
270 : category === "integrations"
271 ? "Browser"
272 : "Repositories"
273 }
274 >
275 {category === "project" ? (
276 <>
277 {modelRow}
278 {workspaceRow}
279 </>
280 ) : category === "general" ? (
281 <>
282 {modelRow}
283 <SettingsRow
284 serverScoped
285 settingKeys={["defaultRuntimeMode"]}
286 mixed={mixedPermissions}
287 {...searchableSetting("default-permissions")}
288 description={
289 isProjectScope
290 ? "Permissions for new threads in this project."
291 : "Default permissions for new threads. Projects can override them."
292 }
293 resetAction={
294 settings.defaultRuntimeMode !== DEFAULT_SERVER_SETTINGS.defaultRuntimeMode ? (
295 <SettingResetButton
296 label="default permissions"
297 onClick={() =>
298 updateSettings({
299 defaultRuntimeMode: DEFAULT_SERVER_SETTINGS.defaultRuntimeMode,
300 })
301 }
302 />
303 ) : null
304 }
305 control={
306 <Select
307 value={mixedPermissions ? null : settings.defaultRuntimeMode}
308 onValueChange={(value) => {
309 if (value) updateSettings({ defaultRuntimeMode: value });
310 }}
311 >
312 <SelectTrigger size="sm" aria-label="Default permissions">
313 {!mixedPermissions && (
314 <PermissionIcon className="size-3.5 shrink-0 text-muted-foreground" />
315 )}
316 <SelectValue>
317 {mixedPermissions
318 ? "Mixed"
319 : runtimeModeConfig[settings.defaultRuntimeMode].label}
320 </SelectValue>
321 </SelectTrigger>
322 <SelectPopup align="end" alignItemWithTrigger={false}>
323 {runtimeModeOptions.map((mode) => {
324 const option = runtimeModeConfig[mode];
325 const Icon = option.icon;
326 return (
327 <SelectItem key={mode} value={mode} className="min-w-64">
328 <div className="grid gap-0.5">
329 <span className="inline-flex items-center gap-1.5 font-medium">
330 <Icon className="size-3.5 shrink-0 text-muted-foreground" />
331 {option.label}
332 </span>
333 <span className="text-xs leading-4 text-muted-foreground">
334 {option.description}
335 </span>
336 </div>
337 </SelectItem>
338 );
339 })}
340 </SelectPopup>
341 </Select>
342 }
343 />
344 {workspaceRow}
345 <SettingsRow
346 serverScoped
347 settingKeys={["worktreeSubmodules"]}
348 mixed={mixedSubmodules}
349 {...searchableSetting("worktree-submodules")}
350 description={
351 isProjectScope
352 ? "How new worktrees in this project populate git submodules."
353 : "How new worktrees populate git submodules. Projects and their t3.json can override it."
354 }
355 resetAction={
356 !isProjectScope && settings.worktreeSubmodules !== null ? (
357 <SettingResetButton
358 label="worktree submodules"
359 onClick={() => updateSettings({ worktreeSubmodules: null })}
360 />
361 ) : null
362 }
363 control={
364 <Select
365 value={mixedSubmodules ? null : (effective?.worktreeSubmodules ?? null)}
366 onValueChange={(value) => {
367 if (isWorktreeSubmodules(value)) updateSettings({ worktreeSubmodules: value });
368 }}
369 >
370 <SelectTrigger size="sm" aria-label="Worktree submodules">
371 <SelectValue>
372 {(value: string | null) =>
373 isWorktreeSubmodules(value)
374 ? WORKTREE_SUBMODULES_LABELS[value]
375 : unavailable
376 ? "Unavailable"
377 : "Mixed"
378 }
379 </SelectValue>
380 </SelectTrigger>
381 <SelectPopup align="end" alignItemWithTrigger={false}>
382 {WORKTREE_SUBMODULES_OPTIONS.map((option) => (
383 <SelectItem key={option} value={option}>
384 {WORKTREE_SUBMODULES_LABELS[option]}
385 </SelectItem>
386 ))}
387 </SelectPopup>
388 </Select>
389 }
390 />
391 </>
392 ) : category === "source-control" ? (
393 <>
394 <SettingsRow
395 serverScoped
396 settingKeys={["defaultAutoPull"]}
397 mixed={mixedAutoPull}
398 id="automatic-pull"
399 title="Automatically pull"
400 description={
401 isProjectScope
402 ? "Keeps this project's default branch current when the checkout has no local changes or commits."
403 : "Keeps the default branch current when the checkout has no local changes or commits. Projects can override it."
404 }
405 resetAction={
406 settings.defaultAutoPull ? (
407 <SettingResetButton
408 label="default automatic pull"
409 tooltip="Reset automatic pull to off"
410 onClick={() => updateSettings({ defaultAutoPull: false })}
411 />
412 ) : null
413 }
414 control={
415 <Switch
416 aria-label="Default automatic pull"
417 mixed={mixedAutoPull}
418 checked={mixedAutoPull ? false : settings.defaultAutoPull}
419 onCheckedChange={(enabled) => updateSettings({ defaultAutoPull: enabled })}
420 />
421 }
422 />
423 <SettingsRow
424 serverScoped
425 settingKeys={["removeAgentCreditsOnMerge"]}
426 mixed={mixedAgentCredits}
427 {...searchableSetting("remove-agent-credits-on-merge")}
428 description="Remove recognized agent credit lines from GitHub merge and squash messages, keeping human co-authors. Includes auto-merge. Excludes merge queues, stack merges, and existing commits."
429 resetAction={
430 settings.removeAgentCreditsOnMerge ? (
431 <SettingResetButton
432 label="agent credit removal"
433 tooltip="Keep agent credits"
434 onClick={() => updateSettings({ removeAgentCreditsOnMerge: false })}
435 />
436 ) : null
437 }
438 control={
439 <Switch
440 aria-label="Remove agent credits when merging"
441 mixed={mixedAgentCredits}
442 checked={mixedAgentCredits ? false : settings.removeAgentCreditsOnMerge}
443 onCheckedChange={(enabled) =>
444 updateSettings({ removeAgentCreditsOnMerge: enabled })
445 }
446 />
447 }
448 />
449 <SettingsRow
450 serverScoped
451 settingKeys={["pullRequestMergeMethod"]}
452 mixed={mixedMergeMethod}
453 {...searchableSetting("pull-request-merge-method")}
454 description={
455 isProjectScope
456 ? "Pull requests in this project start with this method."
457 : "Pull requests start with this method. Last selected reuses whatever you chose most recently on this device."
458 }
459 resetAction={
460 settings.pullRequestMergeMethod !== null ? (
461 <SettingResetButton
462 label="default merge method"
463 tooltip="Reset to last selected"
464 onClick={() => updateSettings({ pullRequestMergeMethod: null })}
465 />
466 ) : null
467 }
468 control={
469 <Select
470 value={mixedMergeMethod ? null : (settings.pullRequestMergeMethod ?? "last")}
471 onValueChange={(value) => {
472 if (value === "last") updateSettings({ pullRequestMergeMethod: null });
473 else if (value === "merge" || value === "squash" || value === "rebase")
474 updateSettings({ pullRequestMergeMethod: value });
475 }}
476 >
477 <SelectTrigger size="sm" aria-label="Default pull request merge method">
478 <SelectValue>
479 {(value: string | null) =>
480 value === "merge" || value === "squash" || value === "rebase"
481 ? PULL_REQUEST_MERGE_METHOD_LABELS[value]
482 : value === "last"
483 ? "Last selected"
484 : "Mixed"
485 }
486 </SelectValue>
487 </SelectTrigger>
488 <SelectPopup align="end" alignItemWithTrigger={false}>
489 <SelectItem value="last">Last selected</SelectItem>
490 <SelectItem value="merge">{PULL_REQUEST_MERGE_METHOD_LABELS.merge}</SelectItem>
491 <SelectItem value="squash">{PULL_REQUEST_MERGE_METHOD_LABELS.squash}</SelectItem>
492 <SelectItem value="rebase">{PULL_REQUEST_MERGE_METHOD_LABELS.rebase}</SelectItem>
493 </SelectPopup>
494 </Select>
495 }
496 />
497 </>
498 ) : (
499 <>
500 <SettingsRow
501 serverScoped
502 settingKeys={["enableAgentBrowserAccess"]}
503 mixed={mixedBrowser}
504 id={searchableSetting("agent-browser-access").id}
505 title="Agent browser access"
506 description={
507 isProjectScope
508 ? "Allow agents in this project to use the shared browser. Applies when the agent session next starts."
509 : "Allow agents to use the shared browser. Projects can override it."
510 }
511 resetAction={
512 settings.enableAgentBrowserAccess !==
513 DEFAULT_SERVER_SETTINGS.enableAgentBrowserAccess ? (
514 <SettingResetButton
515 label="default browser access"
516 onClick={() =>
517 updateSettings({
518 enableAgentBrowserAccess: DEFAULT_SERVER_SETTINGS.enableAgentBrowserAccess,
519 })
520 }
521 />
522 ) : null
523 }
524 control={
525 <Switch
526 aria-label="Agent browser access"
527 mixed={mixedBrowser}
528 checked={mixedBrowser ? false : settings.enableAgentBrowserAccess}
529 onCheckedChange={(enabled) => updateSettings({ enableAgentBrowserAccess: enabled })}
530 />
531 }
532 />
533 </>
534 )}
535 </SettingsSection>
536 );
537}