| 1 | import { useNavigate } from "@tanstack/react-router"; |
| 2 | import { useRef, useState } from "react"; |
| 3 | import type { |
| 4 | ProviderInstanceId, |
| 5 | ServerSettings, |
| 6 | SourceControlWritingStyleMode, |
| 7 | } from "@t3tools/contracts"; |
| 8 | import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; |
| 9 | import { createModelSelection } from "@t3tools/shared/model"; |
| 10 | import { |
| 11 | resolveReviewGuideModelSelection, |
| 12 | resolveSourceControlWriterModelSelection, |
| 13 | } from "@t3tools/shared/serverSettings"; |
| 14 | |
| 15 | import { |
| 16 | useScopedSettings, |
| 17 | useScopedSettingsMixed, |
| 18 | useUpdateScopedSettings, |
| 19 | } from "./useScopedSettings"; |
| 20 | import { useScopedModelDisabledReason } from "./useScopedModelAvailability"; |
| 21 | import { useSettingsScope } from "./SettingsScopeContext"; |
| 22 | import { |
| 23 | applyProviderInstanceSettings, |
| 24 | deriveProviderInstanceEntries, |
| 25 | sortProviderInstanceEntries, |
| 26 | } from "../../providerInstances"; |
| 27 | import { |
| 28 | getCustomModelOptionsByInstance, |
| 29 | resolveAppModelSelectionState, |
| 30 | selectsPlanAgent, |
| 31 | } from "../../modelSelection"; |
| 32 | import { EMPTY_SERVER_PROVIDERS } from "../../state/server"; |
| 33 | import { ProviderModelPicker } from "../chat/ProviderModelPicker"; |
| 34 | import { TraitsPicker } from "../chat/TraitsPicker"; |
| 35 | import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; |
| 36 | import { Switch } from "../ui/switch"; |
| 37 | import { Textarea } from "../ui/textarea"; |
| 38 | import { toastManager } from "../ui/toast"; |
| 39 | import { Button } from "../ui/button"; |
| 40 | import { |
| 41 | SETTINGS_PICKER_TRIGGER_CLASSNAME, |
| 42 | SettingResetButton, |
| 43 | SettingsRow, |
| 44 | SettingsSection, |
| 45 | } from "./settingsLayout"; |
| 46 | import { BranchNamingSettings } from "./BranchNamingSettings"; |
| 47 | import { searchableSetting } from "./settingsSearch"; |
| 48 | |
| 49 | const 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 | |
| 66 | export 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 | } |