1import { useNavigate } from "@tanstack/react-router";
2import { useRef, useState } from "react";
3import type {
4 ProviderInstanceId,
5 ServerSettings,
6 SourceControlWritingStyleMode,
7} from "@t3tools/contracts";
8import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings";
9import { createModelSelection } from "@t3tools/shared/model";
10import {
11 resolveReviewGuideModelSelection,
12 resolveSourceControlWriterModelSelection,
13} from "@t3tools/shared/serverSettings";
14
15import {
16 useScopedSettings,
17 useScopedSettingsMixed,
18 useUpdateScopedSettings,
19} from "./useScopedSettings";
20import { useScopedModelDisabledReason } from "./useScopedModelAvailability";
21import { useSettingsScope } from "./SettingsScopeContext";
22import {
23 applyProviderInstanceSettings,
24 deriveProviderInstanceEntries,
25 sortProviderInstanceEntries,
26} from "../../providerInstances";
27import {
28 getCustomModelOptionsByInstance,
29 resolveAppModelSelectionState,
30 selectsPlanAgent,
31} from "../../modelSelection";
32import { EMPTY_SERVER_PROVIDERS } from "../../state/server";
33import { ProviderModelPicker } from "../chat/ProviderModelPicker";
34import { TraitsPicker } from "../chat/TraitsPicker";
35import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
36import { Switch } from "../ui/switch";
37import { Textarea } from "../ui/textarea";
38import { toastManager } from "../ui/toast";
39import { Button } from "../ui/button";
40import {
41 SETTINGS_PICKER_TRIGGER_CLASSNAME,
42 SettingResetButton,
43 SettingsRow,
44 SettingsSection,
45} from "./settingsLayout";
46import { BranchNamingSettings } from "./BranchNamingSettings";
47import { searchableSetting } from "./settingsSearch";
48
49const MODE_OPTIONS: Record<SourceControlWritingStyleMode, { label: string; description: string }> =
50 {
51 repo_conventions: {
52 label: "Repository conventions",
53 description: "In each project, matches recent change descriptions and change request titles.",
54 },
55 conventional_commits: {
56 label: "Conventional Commits",
57 description: "Use Conventional Commit prefixes and keep change request text concise.",
58 },
59 custom: {
60 label: "Custom instructions",
61 description:
62 "Use your instructions for change descriptions and change requests in every project.",
63 },
64 };
65
66export function SourceControlWritingSettingsSection() {
67 const settings = useScopedSettings();
68 const updateSettings = useUpdateScopedSettings();
69 const mixedReviewGuidesEnabled = useScopedSettingsMixed(["reviewGuidesEnabled"]);
70 const navigate = useNavigate();
71 const { environment, connectedEnvironments, targets, target } = useSettingsScope();
72 // The representative supplies the provider list; a model choice is checked
73 // against every target before it fans out.
74 const environmentId = environment?.environmentId ?? null;
75 const hasServerTargets = connectedEnvironments.length > 0;
76 const serverProviders = environment?.serverConfig?.providers ?? EMPTY_SERVER_PROVIDERS;
77 // The writing style is one object; each control only cares about its own field.
78 const styleFieldMixed = (field: keyof ServerSettings["sourceControlWritingStyle"]) => {
79 const first = targets[0];
80 return (
81 first !== undefined &&
82 targets.some(
83 (candidate) =>
84 candidate.settings.sourceControlWritingStyle[field] !==
85 first.settings.sourceControlWritingStyle[field],
86 )
87 );
88 };
89 const modeMixed = styleFieldMixed("mode");
90 const instructionsMixed = styleFieldMixed("customInstructions");
91 const templatesMixed = styleFieldMixed("followChangeRequestTemplates");
92 const writingStyleMixed = modeMixed || instructionsMixed;
93 const mixedWriterModel = useScopedSettingsMixed(["sourceControlWriterModelSelection"]);
94 const mixedReviewGuideModel = useScopedSettingsMixed(["reviewGuideModelSelection"]);
95 const mixedReviewGuideInstructions = useScopedSettingsMixed(["reviewGuideInstructions"]);
96 const reviewGuideInstructionsEdited = useRef(false);
97 const scopeKey = targets.map((target) => `${target.environmentId}:${target.projectId}`).join(",");
98 const customInstructionsRef = useRef<HTMLTextAreaElement>(null);
99 const [editingAllInstructions, setEditingAllInstructions] = useState(false);
100 const [allInstructions, setAllInstructions] = useState<string | null>(null);
101 const style = settings.sourceControlWritingStyle;
102 const defaults = DEFAULT_UNIFIED_SETTINGS.sourceControlWritingStyle;
103 const isSourceControlWritingStyleDirty =
104 writingStyleMixed ||
105 style.mode !== defaults.mode ||
106 style.customInstructions !== defaults.customInstructions;
107
108 const textGenerationProviders = serverProviders.filter(
109 (provider) => provider.supportsTextGeneration !== false,
110 );
111 const instanceEntries = sortProviderInstanceEntries(
112 applyProviderInstanceSettings(
113 deriveProviderInstanceEntries(textGenerationProviders),
114 settings,
115 target?.projectId ?? null,
116 ),
117 );
118 // The environment default can name an instance this project turned off.
119 const projectEnabledInstanceIds = new Set(
120 instanceEntries
121 .filter((entry) => entry.enabled && entry.isAvailable)
122 .map((entry) => entry.instanceId),
123 );
124 const projectEffectiveTextGenerationProviders = textGenerationProviders.filter((provider) =>
125 projectEnabledInstanceIds.has(provider.instanceId),
126 );
127 const defaultModelSelection = resolveAppModelSelectionState(
128 settings,
129 projectEffectiveTextGenerationProviders,
130 );
131 const usesDedicatedModel = settings.sourceControlWriterModelSelection !== null;
132 const activeSelection = resolveAppModelSelectionState(
133 {
134 ...settings,
135 textGenerationModelSelection: resolveSourceControlWriterModelSelection(
136 settings,
137 projectEffectiveTextGenerationProviders,
138 target?.projectId ?? null,
139 ),
140 },
141 projectEffectiveTextGenerationProviders,
142 );
143 const canEnableDedicatedModel = instanceEntries.some(
144 (entry) =>
145 entry.instanceId === defaultModelSelection.instanceId && entry.enabled && entry.isAvailable,
146 );
147 const modelOptionsByInstance = getCustomModelOptionsByInstance(
148 settings,
149 projectEffectiveTextGenerationProviders,
150 activeSelection.instanceId,
151 activeSelection.model,
152 );
153 const writerModelDisabledReason = useScopedModelDisabledReason(settings, instanceEntries);
154 const writerInstanceEntry = instanceEntries.find(
155 (entry) => entry.instanceId === activeSelection.instanceId,
156 );
157 const usesReviewGuideModel = settings.reviewGuideModelSelection !== null;
158 const reviewGuideSelection = resolveAppModelSelectionState(
159 {
160 ...settings,
161 textGenerationModelSelection: resolveReviewGuideModelSelection(
162 settings,
163 textGenerationProviders,
164 target?.projectId ?? null,
165 ),
166 },
167 textGenerationProviders,
168 );
169 // Its own list: a custom model chosen for guides is not among the writer model's options.
170 const reviewGuideModelOptionsByInstance = getCustomModelOptionsByInstance(
171 settings,
172 textGenerationProviders,
173 reviewGuideSelection.instanceId,
174 reviewGuideSelection.model,
175 );
176 const reviewGuideInstanceEntry = instanceEntries.find(
177 (entry) => entry.instanceId === reviewGuideSelection.instanceId,
178 );
179
180 return (
181 <SettingsSection id="source-control-text-generation" title="Text generation">
182 <BranchNamingSettings />
183 <SettingsRow
184 serverScoped
185 settingKeys={["sourceControlWritingStyle"]}
186 mixed={writingStyleMixed}
187 {...searchableSetting("source-control-writing-style")}
188 description={MODE_OPTIONS[style.mode].description}
189 resetAction={
190 isSourceControlWritingStyleDirty ? (
191 <SettingResetButton
192 label="source control writing style"
193 onClick={() =>
194 updateSettings({
195 sourceControlWritingStyle: {
196 mode: defaults.mode,
197 customInstructions: defaults.customInstructions,
198 },
199 })
200 }
201 />
202 ) : null
203 }
204 control={
205 <Select
206 value={modeMixed ? null : style.mode}
207 onValueChange={(value) => {
208 const customInstructions = customInstructionsRef.current?.value.trim();
209 updateSettings({
210 sourceControlWritingStyle: {
211 mode: value as SourceControlWritingStyleMode,
212 ...(customInstructions !== undefined ? { customInstructions } : {}),
213 },
214 });
215 }}
216 >
217 <SelectTrigger
218 size="sm"
219 className="w-full sm:w-56"
220 aria-label="Source control writing style"
221 >
222 <SelectValue>
223 {(value: SourceControlWritingStyleMode | null) =>
224 value === null ? "Mixed" : MODE_OPTIONS[value].label
225 }
226 </SelectValue>
227 </SelectTrigger>
228 <SelectPopup align="end" alignItemWithTrigger={false}>
229 {(Object.keys(MODE_OPTIONS) as SourceControlWritingStyleMode[]).map((mode) => (
230 <SelectItem key={mode} hideIndicator value={mode}>
231 {MODE_OPTIONS[mode].label}
232 </SelectItem>
233 ))}
234 </SelectPopup>
235 </Select>
236 }
237 >
238 {writingStyleMixed ? (
239 <div className="mt-3 max-w-2xl space-y-2 pb-3.5">
240 {editingAllInstructions ? (
241 <>
242 <Textarea
243 value={allInstructions ?? ""}
244 onChange={(event) => setAllInstructions(event.target.value)}
245 rows={4}
246 aria-label="Custom source control instructions for all selected environments"
247 placeholder="Write the instructions each selected environment should use."
248 />
249 <Button
250 size="sm"
251 variant="outline"
252 disabled={allInstructions === null}
253 onClick={() => {
254 if (allInstructions === null) return;
255 updateSettings({
256 sourceControlWritingStyle: {
257 mode: "custom",
258 customInstructions: allInstructions.trim(),
259 },
260 });
261 setEditingAllInstructions(false);
262 }}
263 >
264 Apply instructions to all
265 </Button>
266 </>
267 ) : (
268 <Button
269 size="sm"
270 variant="outline"
271 onClick={() => {
272 setAllInstructions(null);
273 setEditingAllInstructions(true);
274 }}
275 >
276 Write custom instructions for all
277 </Button>
278 )}
279 </div>
280 ) : style.mode === "custom" ? (
281 <div className="mt-3 max-w-2xl pb-3.5">
282 <Textarea
283 key={style.customInstructions}
284 ref={customInstructionsRef}
285 defaultValue={style.customInstructions}
286 onBlur={(event) => {
287 const customInstructions = event.target.value.trim();
288 if (customInstructions !== style.customInstructions) {
289 updateSettings({ sourceControlWritingStyle: { customInstructions } });
290 }
291 }}
292 rows={4}
293 placeholder="Keep titles concise. Use short bullet points in descriptions."
294 aria-label="Custom source control writing instructions"
295 />
296 </div>
297 ) : null}
298 </SettingsRow>
299
300 <SettingsRow
301 serverScoped
302 settingKeys={["sourceControlWritingStyle"]}
303 mixed={templatesMixed}
304 {...searchableSetting("follow-change-request-templates")}
305 description="Use the repository's template for change request descriptions when available."
306 resetAction={
307 templatesMixed ||
308 style.followChangeRequestTemplates !== defaults.followChangeRequestTemplates ? (
309 <SettingResetButton
310 label="change request templates"
311 onClick={() =>
312 updateSettings({
313 sourceControlWritingStyle: {
314 followChangeRequestTemplates: defaults.followChangeRequestTemplates,
315 },
316 })
317 }
318 />
319 ) : null
320 }
321 control={
322 <Switch
323 mixed={templatesMixed}
324 checked={templatesMixed ? false : style.followChangeRequestTemplates}
325 onCheckedChange={(checked) =>
326 updateSettings({
327 sourceControlWritingStyle: {
328 followChangeRequestTemplates: Boolean(checked),
329 },
330 })
331 }
332 aria-label="Follow change request templates"
333 />
334 }
335 />
336
337 <SettingsRow
338 serverScoped
339 settingKeys={["sourceControlWriterModelSelection"]}
340 {...searchableSetting("source-control-writer-model")}
341 description="Model for source control text and branch or bookmark names. Off uses the environment's text generation model."
342 control={
343 !hasServerTargets ? (
344 <span className="text-sm text-muted-foreground">
345 Connect an environment to choose its source control writer model.
346 </span>
347 ) : (
348 <div className="flex flex-wrap items-center justify-end gap-2">
349 {usesDedicatedModel && !canEnableDedicatedModel ? (
350 <span className="text-sm text-muted-foreground">
351 No text generation providers available.
352 </span>
353 ) : null}
354 {usesDedicatedModel && canEnableDedicatedModel ? (
355 <ProviderModelPicker
356 environmentId={environmentId}
357 activeInstanceId={activeSelection.instanceId}
358 model={activeSelection.model}
359 lockedProvider={null}
360 instanceEntries={instanceEntries}
361 modelOptionsByInstance={modelOptionsByInstance}
362 triggerClassName={SETTINGS_PICKER_TRIGGER_CLASSNAME}
363 triggerAriaLabel="Source control writer model"
364 {...(mixedWriterModel ? { triggerLabel: "Mixed" } : {})}
365 {...(environmentId
366 ? {
367 onOpenProviderSetup: (instanceId: ProviderInstanceId) => {
368 void navigate({
369 to: "/settings/providers",
370 search: { environmentId, instanceId },
371 });
372 },
373 }
374 : {})}
375 getModelDisabledReason={writerModelDisabledReason}
376 onInstanceModelChange={(instanceId, model) => {
377 const reason = writerModelDisabledReason(instanceId, model);
378 if (reason) {
379 toastManager.add({
380 type: "error",
381 title: "Source control writer model not saved",
382 description: reason,
383 });
384 return;
385 }
386 updateSettings({
387 sourceControlWriterModelSelection: createModelSelection(instanceId, model),
388 });
389 }}
390 />
391 ) : null}
392 {usesDedicatedModel &&
393 canEnableDedicatedModel &&
394 !mixedWriterModel &&
395 writerInstanceEntry ? (
396 <TraitsPicker
397 provider={writerInstanceEntry.driverKind}
398 models={writerInstanceEntry.models}
399 model={activeSelection.model}
400 prompt=""
401 onPromptChange={() => {}}
402 modelOptions={activeSelection.options ?? []}
403 allowPromptInjectedEffort={false}
404 planModeEnabled={
405 settings.planModeEnabled || selectsPlanAgent(activeSelection.options)
406 }
407 triggerClassName={SETTINGS_PICKER_TRIGGER_CLASSNAME}
408 onModelOptionsChange={(options) =>
409 updateSettings({
410 sourceControlWriterModelSelection: createModelSelection(
411 activeSelection.instanceId,
412 activeSelection.model,
413 options,
414 ),
415 })
416 }
417 />
418 ) : null}
419 <Switch
420 checked={usesDedicatedModel}
421 disabled={!usesDedicatedModel && !canEnableDedicatedModel}
422 onCheckedChange={(checked) =>
423 updateSettings({
424 sourceControlWriterModelSelection: checked
425 ? createModelSelection(
426 defaultModelSelection.instanceId,
427 defaultModelSelection.model,
428 defaultModelSelection.options,
429 )
430 : null,
431 })
432 }
433 aria-label="Use a separate source control writer model"
434 />
435 </div>
436 )
437 }
438 />
439
440 {/* A beta the server owns: off hides the Guide tab in every client and refuses agents. */}
441 <SettingsRow
442 serverScoped
443 settingKeys={["reviewGuidesEnabled"]}
444 mixed={mixedReviewGuidesEnabled}
445 {...searchableSetting("review-guides")}
446 description="Adds a Guide tab to pull requests: an agent writes a tour of the change, stop by stop, with the calls a maintainer should make."
447 control={
448 <Switch
449 mixed={mixedReviewGuidesEnabled}
450 checked={mixedReviewGuidesEnabled ? false : settings.reviewGuidesEnabled}
451 onCheckedChange={(checked) => updateSettings({ reviewGuidesEnabled: Boolean(checked) })}
452 aria-label="Review guides"
453 />
454 }
455 />
456
457 {settings.reviewGuidesEnabled || mixedReviewGuidesEnabled ? (
458 <>
459 <SettingsRow
460 serverScoped
461 settingKeys={["reviewGuideModelSelection"]}
462 {...searchableSetting("review-guide-model")}
463 description="Model that reads a pull request to write its review guide. It reads the change itself over many steps, so a stronger model helps. Off uses the source control writer model."
464 control={
465 !hasServerTargets ? (
466 <span className="text-sm text-muted-foreground">
467 Connect an environment to choose its review guide model.
468 </span>
469 ) : (
470 <div className="flex flex-wrap items-center justify-end gap-2">
471 {(usesReviewGuideModel || mixedReviewGuideModel) && canEnableDedicatedModel ? (
472 <ProviderModelPicker
473 environmentId={environmentId}
474 activeInstanceId={reviewGuideSelection.instanceId}
475 model={reviewGuideSelection.model}
476 lockedProvider={null}
477 instanceEntries={instanceEntries}
478 modelOptionsByInstance={reviewGuideModelOptionsByInstance}
479 triggerClassName={SETTINGS_PICKER_TRIGGER_CLASSNAME}
480 triggerAriaLabel="Review guide model"
481 {...(mixedReviewGuideModel ? { triggerLabel: "Mixed" } : {})}
482 getModelDisabledReason={writerModelDisabledReason}
483 onInstanceModelChange={(instanceId, model) => {
484 const reason = writerModelDisabledReason(instanceId, model);
485 if (reason) {
486 toastManager.add({
487 type: "error",
488 title: "Review guide model not saved",
489 description: reason,
490 });
491 return;
492 }
493 updateSettings({
494 reviewGuideModelSelection: createModelSelection(instanceId, model),
495 });
496 }}
497 />
498 ) : null}
499 {usesReviewGuideModel &&
500 canEnableDedicatedModel &&
501 !mixedReviewGuideModel &&
502 reviewGuideInstanceEntry ? (
503 <TraitsPicker
504 provider={reviewGuideInstanceEntry.driverKind}
505 models={reviewGuideInstanceEntry.models}
506 model={reviewGuideSelection.model}
507 prompt=""
508 onPromptChange={() => {}}
509 modelOptions={reviewGuideSelection.options ?? []}
510 allowPromptInjectedEffort={false}
511 planModeEnabled={
512 settings.planModeEnabled || selectsPlanAgent(reviewGuideSelection.options)
513 }
514 triggerClassName={SETTINGS_PICKER_TRIGGER_CLASSNAME}
515 onModelOptionsChange={(options) =>
516 updateSettings({
517 reviewGuideModelSelection: createModelSelection(
518 reviewGuideSelection.instanceId,
519 reviewGuideSelection.model,
520 options,
521 ),
522 })
523 }
524 />
525 ) : null}
526 <Switch
527 mixed={mixedReviewGuideModel}
528 checked={mixedReviewGuideModel ? false : usesReviewGuideModel}
529 disabled={!usesReviewGuideModel && !canEnableDedicatedModel}
530 onCheckedChange={(checked) => {
531 if (!checked) {
532 updateSettings({ reviewGuideModelSelection: null });
533 return;
534 }
535 // Every selected target gets this model, so it must run on all of them.
536 const reason = writerModelDisabledReason(
537 activeSelection.instanceId,
538 activeSelection.model,
539 );
540 if (reason) {
541 toastManager.add({
542 type: "error",
543 title: "Review guide model not saved",
544 description: reason,
545 });
546 return;
547 }
548 updateSettings({
549 reviewGuideModelSelection: createModelSelection(
550 activeSelection.instanceId,
551 activeSelection.model,
552 activeSelection.options,
553 ),
554 });
555 }}
556 aria-label="Use a separate review guide model"
557 />
558 </div>
559 )
560 }
561 />
562
563 <SettingsRow
564 serverScoped
565 settingKeys={["reviewGuideInstructions"]}
566 {...searchableSetting("review-guide-instructions")}
567 description="Your preferences for review guides: what to show first, dwell on, or skip. The guide's format and coverage stay the same."
568 resetAction={
569 mixedReviewGuideInstructions || settings.reviewGuideInstructions !== "" ? (
570 <SettingResetButton
571 label="review guide instructions"
572 onClick={() => updateSettings({ reviewGuideInstructions: "" })}
573 />
574 ) : null
575 }
576 >
577 <div className="mt-3 max-w-2xl pb-3.5">
578 <Textarea
579 key={`${scopeKey}:${mixedReviewGuideInstructions}:${settings.reviewGuideInstructions}`}
580 aria-label="Review guide instructions"
581 onChange={() => {
582 reviewGuideInstructionsEdited.current = true;
583 }}
584 rows={4}
585 defaultValue={mixedReviewGuideInstructions ? "" : settings.reviewGuideInstructions}
586 placeholder={
587 mixedReviewGuideInstructions
588 ? "Mixed. Enter instructions to apply to all selected targets."
589 : "Show service interfaces before implementations. Skip test files unless they change behavior. Keep summaries to two sentences."
590 }
591 onBlur={(event) => {
592 const value = event.target.value.trim();
593 if (
594 reviewGuideInstructionsEdited.current &&
595 (mixedReviewGuideInstructions || value !== settings.reviewGuideInstructions)
596 )
597 updateSettings({ reviewGuideInstructions: value });
598 reviewGuideInstructionsEdited.current = false;
599 }}
600 />
601 </div>
602 </SettingsRow>
603 </>
604 ) : null}
605 </SettingsSection>
606 );
607}