import { useAtomValue } from "@effect/atom-react";import { isAtomCommandInterrupted, mapAtomCommandResult, settlePromise, squashAtomCommandFailure, type AtomCommandResult,} from "@t3tools/client-runtime/state/runtime";import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment";import { AsyncResult } from "effect/unstable/reactivity";import { deriveProjectGroupingOverrideKey, selectProjectGroupingSettings,} from "../../logicalProject";import { type EnvironmentId, type ModelSelection, type ProjectIconOverride, type ProjectId, type ProjectScript, type ResolvedKeybindingsConfig, type ServerSettings, type ProviderDriverKind, type PullRequestMergeMethod, type SidebarProjectGroupingMode, type T3ProjectFileScript, type ThreadEnvMode,} from "@t3tools/contracts";import { resolveEnvModeLabel } from "../BranchToolbar.logic";import { createModelSelection } from "@t3tools/shared/model";import { resolveProjectAutoPull } from "@t3tools/shared/serverSettings";import { projectScriptsInheritDefaults, resolveProjectScripts,} from "@t3tools/shared/projectScripts";import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings";import { useNavigate } from "@tanstack/react-router";import * as Equal from "effect/Equal";import * as Cause from "effect/Cause";import { ChevronDownIcon, PlusIcon, Trash2Icon } from "lucide-react";import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useComposerDraftStore } from "../../composerDraftStore";import { useClientSettings, useEnvironmentSettings, useUpdateClientSettings,} from "../../hooks/useSettings";import { useT3ProjectFileState } from "../../hooks/useT3ProjectFileScripts";import { ProjectActionsList } from "./ProjectActionsList";import { isElectron } from "../../env";import { decodeProjectScriptKeybindingRule, keybindingValueForCommand,} from "../../lib/projectScriptKeybindings";import { buildProjectScript, commandForProjectScript, nextProjectScriptId,} from "../../projectScripts";import { releaseProjectDraftUploads } from "../../lib/composerDraftUploads";import { readLocalApi } from "../../localApi";import { applyProviderInstanceSettings, deriveProviderInstanceEntries, resolveDefaultProviderModelSelection, sortProviderInstanceEntries,} from "../../providerInstances";import { getCustomModelOptionsByInstance } from "../../modelSelection";import { buildSidebarProjectSnapshots, type SidebarProjectGroupMember, type SidebarProjectSnapshot,} from "../../sidebarProjectGrouping";import { useEnvironments, usePrimaryEnvironmentId } from "../../state/environments";import { useProjects, useThreadShells } from "../../state/entities";import { projectEnvironment } from "../../state/projects";import { EMPTY_SERVER_PROVIDERS, serverEnvironment } from "../../state/server";import { useAtomCommand } from "../../state/use-atom-command";import { ProviderModelPicker } from "../chat/ProviderModelPicker";import { TraitsPicker } from "../chat/TraitsPicker";import { ProjectFavicon } from "../ProjectFavicon";import { PULL_REQUEST_MERGE_METHOD_LABELS } from "../pullRequest/pullRequestDetail.logic";import { EMPTY_PROJECT_SCRIPT_INPUT, editorRequestForScript, ProjectScriptEditorDialog, ScriptIcon, type NewProjectScriptInput, type ProjectScriptEditorRequest,} from "../projectScriptEditor";import { Button } from "../ui/button";import { Input } from "../ui/input";import { Menu, MenuGroup, MenuGroupLabel, MenuItem, MenuPopup, MenuSeparator, MenuTrigger,} from "../ui/menu";import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";import { Switch } from "../ui/switch";import { stackedThreadToast, toastManager } from "../ui/toast";import { SETTINGS_PICKER_TRIGGER_CLASSNAME, SettingResetButton, SettingsPageContainer, SettingsRow, SettingsSection,} from "./settingsLayout";import { canPickExternalProjectFavicon, ProjectFaviconPickerDialog,} from "./ProjectFaviconPickerDialog";import { projectGroupTitleNeedsUpdate } from "./ProjectSettingsPanel.logic"; const ProjectIconPickerDialog = lazy(() => import("./ProjectIconPickerDialog").then((module) => ({ default: module.ProjectIconPickerDialog, })),); export const PROJECT_GROUPING_MODE_LABELS: Record<SidebarProjectGroupingMode, string> = { repository: "Group by repository", repository_path: "Group by repository path", separate: "Keep separate",}; /** Logical project groups for the settings page, sorted by display name. */export function useSettingsProjectGroups(): SidebarProjectSnapshot[] { const projects = useProjects(); const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); const primaryEnvironmentId = usePrimaryEnvironmentId(); const { environments } = useEnvironments(); const environmentLabelById = useMemo( () => new Map( environments.map((environment) => [environment.environmentId, environment.label] as const), ), [environments], ); return useMemo( () => buildSidebarProjectSnapshots({ projects, settings: projectGroupingSettings, primaryEnvironmentId, resolveEnvironmentLabel: (environmentId) => environmentLabelById.get(environmentId) ?? null, }).sort((a, b) => a.displayName.localeCompare(b.displayName)), [environmentLabelById, primaryEnvironmentId, projectGroupingSettings, projects], );} function memberKey(member: { environmentId: string; id: string }): string { return `${member.environmentId}:${member.id}`;} export function ProjectSettingsPanel({ projectKey, environmentId = null,}: { projectKey: string; environmentId?: EnvironmentId | null;}) { const groups = useSettingsProjectGroups(); const navigate = useNavigate(); const selected = groups.find((group) => group.projectKey === projectKey) ?? null; const members = useMemo( () => selected?.memberProjects.filter( (member) => environmentId === null || member.environmentId === environmentId, ) ?? [], [selected, environmentId], ); // Remember the members of the last rendered group so a grouping-rule change // (which changes the group key) can follow the project to its new group. const lastSelectionRef = useRef<{ key: string; environmentId: EnvironmentId | null; memberKeys: string[]; } | null>(null); useEffect(() => { if (!selected || members.length === 0) return; lastSelectionRef.current = { key: selected.projectKey, environmentId, memberKeys: members.map((member) => member.physicalProjectKey), }; }, [selected, members, environmentId]); // A grouping-rule change replaces the group key mid-visit; follow the // project to its new key instead of parking on the not-found state. useEffect(() => { if (members.length > 0) return; const last = lastSelectionRef.current; if (last?.key !== projectKey || last.environmentId !== environmentId) return; const successor = groups.find((group) => group.memberProjects.some((member) => last.memberKeys.includes(member.physicalProjectKey)), ); if (successor) { void navigate({ to: "/settings/projects", search: { project: successor.projectKey, machine: environmentId ?? undefined }, replace: true, hashScrollIntoView: false, }); } }, [groups, navigate, projectKey, members.length, environmentId]); if (!selected) { return ( <div className="flex flex-1 items-center justify-center p-8 text-sm text-muted-foreground"> {groups.length === 0 ? "Add a project from the sidebar to configure it here." : "This project is no longer available."} </div> ); } if (members.length === 0) return ( <p className="p-8 text-sm text-muted-foreground"> This project has no checkout on this machine. </p> ); const scopedGroup = { ...selected, memberProjects: members, environmentId: members[0]!.environmentId, id: members[0]!.id, }; return ( <ProjectDetail key={`${selected.projectKey}:${environmentId ?? "all"}`} group={scopedGroup} hasOtherMembers={members.length < selected.memberProjects.length} /> );} function reportScriptFailure(result: AtomCommandResult<unknown, unknown>) { if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { const error = squashAtomCommandFailure(result); toastManager.add({ type: "error", title: "Failed to save project actions", description: error instanceof Error ? error.message : "An error occurred.", }); } return mapAtomCommandResult(result, () => undefined);} export function useProjectScriptSettings( targets: readonly { environmentId: EnvironmentId; settings: ServerSettings; keybindings: ResolvedKeybindingsConfig; project?: { id: ProjectId; scripts: readonly ProjectScript[] }; }[],) { const projects = useProjects(); const [saving, setSaving] = useState(false); const savingRef = useRef(false); const updateSettings = useAtomCommand(serverEnvironment.updateSettings, "project actions update"); const upsertKeybinding = useAtomCommand( serverEnvironment.upsertKeybinding, "action shortcut update", ); const removeKeybinding = useAtomCommand( serverEnvironment.removeKeybinding, "action shortcut removal", ); async function persist( transform: (current: readonly ProjectScript[]) => readonly ProjectScript[] | null, scriptId?: string, keybinding?: string | null, ): Promise<AtomCommandResult<void, unknown>> { if (savingRef.current || targets.length === 0) { const message = "No available machine, or another action change is saving."; toastManager.add({ type: "error", title: "Actions not saved", description: message }); return AsyncResult.failure(Cause.fail(new Error(message))); } savingRef.current = true; setSaving(true); try { for (const { environmentId, settings, keybindings, project } of targets) { const current = project ? resolveProjectScripts(settings, project) : settings.defaultProjectScripts; const nextScripts = transform(current); const effectiveScripts = nextScripts ?? settings.defaultProjectScripts; const result = await updateSettings({ environmentId, input: { patch: project ? { projectScriptOverrides: { [project.id]: nextScripts } } : { defaultProjectScripts: nextScripts ?? [] }, }, }); if (result._tag === "Failure") return reportScriptFailure(result); if (!isElectron) continue; const changedIds = scriptId ? [scriptId] : current .filter((script) => !effectiveScripts.some((next) => next.id === script.id)) .map((script) => script.id); for (const id of changedIds) { const command = commandForProjectScript(id); const previousValue = keybindingValueForCommand(keybindings, command); const previous = previousValue ? decodeProjectScriptKeybindingRule({ keybinding: previousValue, command }) : null; const next = decodeProjectScriptKeybindingRule({ keybinding, command }); const retainedElsewhere = !nextScripts?.some((script) => script.id === id) && ((project && settings.defaultProjectScripts.some((script) => script.id === id)) || Object.entries(settings.projectScriptOverrides).some( ([projectId, scripts]) => projectId !== project?.id && scripts?.some((script) => script.id === id), ) || projects.some( (other) => other.environmentId === environmentId && other.id !== project?.id && (project ? resolveProjectScripts(settings, other) : other.scripts).some( (script) => script.id === id, ), )); const bindingResult = next ? await upsertKeybinding({ environmentId, input: previous && previous.key !== next.key ? { ...next, replace: previous } : next, }) : previous && !retainedElsewhere ? await removeKeybinding({ environmentId, input: previous }) : null; if (bindingResult?._tag === "Failure") return reportScriptFailure(bindingResult); } } return AsyncResult.success(undefined); } finally { savingRef.current = false; setSaving(false); } } function submit(scriptId: string | null, input: NewProjectScriptInput) { const existingIds = [ ...projects.flatMap((project) => project.scripts.map((script) => script.id)), ...targets.flatMap(({ settings, project }) => [ ...settings.defaultProjectScripts, ...Object.values(settings.projectScriptOverrides).flatMap((scripts) => scripts ?? []), ...(project?.scripts ?? []), ].map((script) => script.id), ), ]; const id = scriptId ?? nextProjectScriptId(input.name, existingIds); const next = buildProjectScript(id, input); return persist( (current) => { const updated = current.map((script) => script.id === id ? next : input.runOnWorktreeCreate ? { ...script, runOnWorktreeCreate: false } : script, ); return scriptId === null ? [...updated, next] : updated; }, id, input.keybinding, ); } return { saving, persist, submit };} function ProjectDetail({ group, hasOtherMembers,}: { group: SidebarProjectSnapshot; hasOtherMembers: boolean;}) { const navigate = useNavigate(); const primaryEnvironmentId = usePrimaryEnvironmentId(); const { environments } = useEnvironments(); const environmentById = useMemo( () => new Map(environments.map((environment) => [environment.environmentId, environment])), [environments], ); const representative = group.memberProjects.find( (member) => environmentById.get(member.environmentId)?.serverConfig != null, ) ?? group.memberProjects[0]!; // Provider instances and model options belong to the environment that runs // the project's threads. The hosted app has no primary environment, so // reading them from there would show "No providers available" everywhere. const projectSettings = useEnvironmentSettings(representative.environmentId); const serverProviders = useAtomValue(serverEnvironment.providersValueAtom(representative.environmentId)) ?? EMPTY_SERVER_PROVIDERS; const updateClientSettings = useUpdateClientSettings(); const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); const threads = useThreadShells(); const updateProject = useAtomCommand(projectEnvironment.update, { reportFailure: false }); const updateServerSettings = useAtomCommand(serverEnvironment.updateSettings, "project setting"); const [savingBrowserAccess, setSavingBrowserAccess] = useState(false); const savingBrowserAccessRef = useRef(false); const browserOverrides = group.memberProjects.map( (member) => environmentById.get(member.environmentId)?.serverConfig?.settings .projectAgentBrowserAccessOverrides[member.id], ); const browserOverride = projectSettings.projectAgentBrowserAccessOverrides[representative.id]; const browserMixed = group.memberProjects.some((member, index) => { const settings = environmentById.get(member.environmentId)?.serverConfig?.settings; if (!settings || !environmentById.get(representative.environmentId)?.serverConfig) return false; return ( browserOverrides[index] !== browserOverride || (browserOverrides[index] ?? settings.enableAgentBrowserAccess) !== (browserOverride ?? projectSettings.enableAgentBrowserAccess) ); }); const setBooleanOverride = async ( key: "projectAgentBrowserAccessOverrides" | "projectAutoPullOverrides", enabled: boolean | undefined, ) => { if (savingBrowserAccessRef.current) return; savingBrowserAccessRef.current = true; setSavingBrowserAccess(true); try { const environmentIds = new Set(group.memberProjects.map((member) => member.environmentId)); for (const environmentId of environmentIds) { const environment = environmentById.get(environmentId); if (!environment?.serverConfig || environment.connection.phase !== "connected") { toastManager.add({ type: "warning", title: "Setting not saved", description: `Connect ${environment?.label ?? "this machine"} and try again.`, }); return; } } if (key === "projectAutoPullOverrides" && enabled === undefined) { const result = await updateAllMembers( { autoPull: false }, "Failed to reset automatic pull", ); if (result._tag === "Failure") return; } for (const environmentId of environmentIds) { const overrides = Object.fromEntries( group.memberProjects .filter((member) => member.environmentId === environmentId) .map((member) => [member.id, enabled ?? null]), ); const result = await updateServerSettings({ environmentId, input: { patch: { [key]: overrides } }, }); if (result._tag === "Failure") { reportFailure( `Failed to save project setting on ${environmentById.get(environmentId)?.label ?? "this machine"}`, mapAtomCommandResult(result, () => undefined), ); return; } } } finally { savingBrowserAccessRef.current = false; setSavingBrowserAccess(false); } }; const setBrowserAccess = (enabled: boolean | undefined) => setBooleanOverride("projectAgentBrowserAccessOverrides", enabled); const deleteProject = useAtomCommand(projectEnvironment.delete, { reportFailure: false }); const projectNameEditedRef = useRef(false); const mergeMethodOverrides = useClientSettings( (settings) => settings.pullRequestMergeMethodOverrides, ); const projectMergeMethod = mergeMethodOverrides[group.projectKey]; const setProjectMergeMethod = (method: PullRequestMergeMethod | null) => { const nextOverrides = { ...mergeMethodOverrides }; if (method === null) { delete nextOverrides[group.projectKey]; } else { nextOverrides[group.projectKey] = method; } updateClientSettings({ pullRequestMergeMethodOverrides: nextOverrides }); }; const faviconPath = representative.faviconPath ?? null; const projectIcon = representative.projectIcon ?? null;Lines 1–500 of 1483. Load more
import { useAtomValue } from "@effect/atom-react"; import { isAtomCommandInterrupted, mapAtomCommandResult, settlePromise, squashAtomCommandFailure, type AtomCommandResult, } from "@t3tools/client-runtime/state/runtime"; import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; import { AsyncResult } from "effect/unstable/reactivity"; import { deriveProjectGroupingOverrideKey, selectProjectGroupingSettings, } from "../../logicalProject"; import { type EnvironmentId, type ModelSelection, type ProjectIconOverride, type ProjectId, type ProjectScript, type ResolvedKeybindingsConfig, type ServerSettings, type ProviderDriverKind, type PullRequestMergeMethod, type SidebarProjectGroupingMode, type T3ProjectFileScript, type ThreadEnvMode, } from "@t3tools/contracts"; import { resolveEnvModeLabel } from "../BranchToolbar.logic"; import { createModelSelection } from "@t3tools/shared/model"; import { resolveProjectAutoPull } from "@t3tools/shared/serverSettings"; import { projectScriptsInheritDefaults, resolveProjectScripts, } from "@t3tools/shared/projectScripts"; import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; import { useNavigate } from "@tanstack/react-router"; import * as Equal from "effect/Equal"; import * as Cause from "effect/Cause"; import { ChevronDownIcon, PlusIcon, Trash2Icon } from "lucide-react"; import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useComposerDraftStore } from "../../composerDraftStore"; import { useClientSettings, useEnvironmentSettings, useUpdateClientSettings, } from "../../hooks/useSettings"; import { useT3ProjectFileState } from "../../hooks/useT3ProjectFileScripts"; import { ProjectActionsList } from "./ProjectActionsList"; import { isElectron } from "../../env"; import { decodeProjectScriptKeybindingRule, keybindingValueForCommand, } from "../../lib/projectScriptKeybindings"; import { buildProjectScript, commandForProjectScript, nextProjectScriptId, } from "../../projectScripts"; import { releaseProjectDraftUploads } from "../../lib/composerDraftUploads"; import { readLocalApi } from "../../localApi"; import { applyProviderInstanceSettings, deriveProviderInstanceEntries, resolveDefaultProviderModelSelection, sortProviderInstanceEntries, } from "../../providerInstances"; import { getCustomModelOptionsByInstance } from "../../modelSelection"; import { buildSidebarProjectSnapshots, type SidebarProjectGroupMember, type SidebarProjectSnapshot, } from "../../sidebarProjectGrouping"; import { useEnvironments, usePrimaryEnvironmentId } from "../../state/environments"; import { useProjects, useThreadShells } from "../../state/entities"; import { projectEnvironment } from "../../state/projects"; import { EMPTY_SERVER_PROVIDERS, serverEnvironment } from "../../state/server"; import { useAtomCommand } from "../../state/use-atom-command"; import { ProviderModelPicker } from "../chat/ProviderModelPicker"; import { TraitsPicker } from "../chat/TraitsPicker"; import { ProjectFavicon } from "../ProjectFavicon"; import { PULL_REQUEST_MERGE_METHOD_LABELS } from "../pullRequest/pullRequestDetail.logic"; import { EMPTY_PROJECT_SCRIPT_INPUT, editorRequestForScript, ProjectScriptEditorDialog, ScriptIcon, type NewProjectScriptInput, type ProjectScriptEditorRequest, } from "../projectScriptEditor"; import { Button } from "../ui/button"; import { Input } from "../ui/input"; import { Menu, MenuGroup, MenuGroupLabel, MenuItem, MenuPopup, MenuSeparator, MenuTrigger, } from "../ui/menu"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; import { Switch } from "../ui/switch"; import { stackedThreadToast, toastManager } from "../ui/toast"; import { SETTINGS_PICKER_TRIGGER_CLASSNAME, SettingResetButton, SettingsPageContainer, SettingsRow, SettingsSection, } from "./settingsLayout"; import { canPickExternalProjectFavicon, ProjectFaviconPickerDialog, } from "./ProjectFaviconPickerDialog"; import { projectGroupTitleNeedsUpdate } from "./ProjectSettingsPanel.logic"; const ProjectIconPickerDialog = lazy(() => import("./ProjectIconPickerDialog").then((module) => ({ default: module.ProjectIconPickerDialog, })), ); export const PROJECT_GROUPING_MODE_LABELS: Record<SidebarProjectGroupingMode, string> = { repository: "Group by repository", repository_path: "Group by repository path", separate: "Keep separate", }; /** Logical project groups for the settings page, sorted by display name. */ export function useSettingsProjectGroups(): SidebarProjectSnapshot[] { const projects = useProjects(); const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); const primaryEnvironmentId = usePrimaryEnvironmentId(); const { environments } = useEnvironments(); const environmentLabelById = useMemo( () => new Map( environments.map((environment) => [environment.environmentId, environment.label] as const), ), [environments], ); return useMemo( () => buildSidebarProjectSnapshots({ projects, settings: projectGroupingSettings, primaryEnvironmentId, resolveEnvironmentLabel: (environmentId) => environmentLabelById.get(environmentId) ?? null, }).sort((a, b) => a.displayName.localeCompare(b.displayName)), [environmentLabelById, primaryEnvironmentId, projectGroupingSettings, projects], ); } function memberKey(member: { environmentId: string; id: string }): string { return `${member.environmentId}:${member.id}`; } export function ProjectSettingsPanel({ projectKey, environmentId = null, }: { projectKey: string; environmentId?: EnvironmentId | null; }) { const groups = useSettingsProjectGroups(); const navigate = useNavigate(); const selected = groups.find((group) => group.projectKey === projectKey) ?? null; const members = useMemo( () => selected?.memberProjects.filter( (member) => environmentId === null || member.environmentId === environmentId, ) ?? [], [selected, environmentId], ); // Remember the members of the last rendered group so a grouping-rule change // (which changes the group key) can follow the project to its new group. const lastSelectionRef = useRef<{ key: string; environmentId: EnvironmentId | null; memberKeys: string[]; } | null>(null); useEffect(() => { if (!selected || members.length === 0) return; lastSelectionRef.current = { key: selected.projectKey, environmentId, memberKeys: members.map((member) => member.physicalProjectKey), }; }, [selected, members, environmentId]); // A grouping-rule change replaces the group key mid-visit; follow the // project to its new key instead of parking on the not-found state. useEffect(() => { if (members.length > 0) return; const last = lastSelectionRef.current; if (last?.key !== projectKey || last.environmentId !== environmentId) return; const successor = groups.find((group) => group.memberProjects.some((member) => last.memberKeys.includes(member.physicalProjectKey)), ); if (successor) { void navigate({ to: "/settings/projects", search: { project: successor.projectKey, machine: environmentId ?? undefined }, replace: true, hashScrollIntoView: false, }); } }, [groups, navigate, projectKey, members.length, environmentId]); if (!selected) { return ( <div className="flex flex-1 items-center justify-center p-8 text-sm text-muted-foreground"> {groups.length === 0 ? "Add a project from the sidebar to configure it here." : "This project is no longer available."} </div> ); } if (members.length === 0) return ( <p className="p-8 text-sm text-muted-foreground"> This project has no checkout on this machine. </p> ); const scopedGroup = { ...selected, memberProjects: members, environmentId: members[0]!.environmentId, id: members[0]!.id, }; return ( <ProjectDetail key={`${selected.projectKey}:${environmentId ?? "all"}`} group={scopedGroup} hasOtherMembers={members.length < selected.memberProjects.length} /> ); } function reportScriptFailure(result: AtomCommandResult<unknown, unknown>) { if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { const error = squashAtomCommandFailure(result); toastManager.add({ type: "error", title: "Failed to save project actions", description: error instanceof Error ? error.message : "An error occurred.", }); } return mapAtomCommandResult(result, () => undefined); } export function useProjectScriptSettings( targets: readonly { environmentId: EnvironmentId; settings: ServerSettings; keybindings: ResolvedKeybindingsConfig; project?: { id: ProjectId; scripts: readonly ProjectScript[] }; }[], ) { const projects = useProjects(); const [saving, setSaving] = useState(false); const savingRef = useRef(false); const updateSettings = useAtomCommand(serverEnvironment.updateSettings, "project actions update"); const upsertKeybinding = useAtomCommand( serverEnvironment.upsertKeybinding, "action shortcut update", ); const removeKeybinding = useAtomCommand( serverEnvironment.removeKeybinding, "action shortcut removal", ); async function persist( transform: (current: readonly ProjectScript[]) => readonly ProjectScript[] | null, scriptId?: string, keybinding?: string | null, ): Promise<AtomCommandResult<void, unknown>> { if (savingRef.current || targets.length === 0) { const message = "No available machine, or another action change is saving."; toastManager.add({ type: "error", title: "Actions not saved", description: message }); return AsyncResult.failure(Cause.fail(new Error(message))); } savingRef.current = true; setSaving(true); try { for (const { environmentId, settings, keybindings, project } of targets) { const current = project ? resolveProjectScripts(settings, project) : settings.defaultProjectScripts; const nextScripts = transform(current); const effectiveScripts = nextScripts ?? settings.defaultProjectScripts; const result = await updateSettings({ environmentId, input: { patch: project ? { projectScriptOverrides: { [project.id]: nextScripts } } : { defaultProjectScripts: nextScripts ?? [] }, }, }); if (result._tag === "Failure") return reportScriptFailure(result); if (!isElectron) continue; const changedIds = scriptId ? [scriptId] : current .filter((script) => !effectiveScripts.some((next) => next.id === script.id)) .map((script) => script.id); for (const id of changedIds) { const command = commandForProjectScript(id); const previousValue = keybindingValueForCommand(keybindings, command); const previous = previousValue ? decodeProjectScriptKeybindingRule({ keybinding: previousValue, command }) : null; const next = decodeProjectScriptKeybindingRule({ keybinding, command }); const retainedElsewhere = !nextScripts?.some((script) => script.id === id) && ((project && settings.defaultProjectScripts.some((script) => script.id === id)) || Object.entries(settings.projectScriptOverrides).some( ([projectId, scripts]) => projectId !== project?.id && scripts?.some((script) => script.id === id), ) || projects.some( (other) => other.environmentId === environmentId && other.id !== project?.id && (project ? resolveProjectScripts(settings, other) : other.scripts).some( (script) => script.id === id, ), )); const bindingResult = next ? await upsertKeybinding({ environmentId, input: previous && previous.key !== next.key ? { ...next, replace: previous } : next, }) : previous && !retainedElsewhere ? await removeKeybinding({ environmentId, input: previous }) : null; if (bindingResult?._tag === "Failure") return reportScriptFailure(bindingResult); } } return AsyncResult.success(undefined); } finally { savingRef.current = false; setSaving(false); } } function submit(scriptId: string | null, input: NewProjectScriptInput) { const existingIds = [ ...projects.flatMap((project) => project.scripts.map((script) => script.id)), ...targets.flatMap(({ settings, project }) => [ ...settings.defaultProjectScripts, ...Object.values(settings.projectScriptOverrides).flatMap((scripts) => scripts ?? []), ...(project?.scripts ?? []), ].map((script) => script.id), ), ]; const id = scriptId ?? nextProjectScriptId(input.name, existingIds); const next = buildProjectScript(id, input); return persist( (current) => { const updated = current.map((script) => script.id === id ? next : input.runOnWorktreeCreate ? { ...script, runOnWorktreeCreate: false } : script, ); return scriptId === null ? [...updated, next] : updated; }, id, input.keybinding, ); } return { saving, persist, submit }; } function ProjectDetail({ group, hasOtherMembers, }: { group: SidebarProjectSnapshot; hasOtherMembers: boolean; }) { const navigate = useNavigate(); const primaryEnvironmentId = usePrimaryEnvironmentId(); const { environments } = useEnvironments(); const environmentById = useMemo( () => new Map(environments.map((environment) => [environment.environmentId, environment])), [environments], ); const representative = group.memberProjects.find( (member) => environmentById.get(member.environmentId)?.serverConfig != null, ) ?? group.memberProjects[0]!; // Provider instances and model options belong to the environment that runs // the project's threads. The hosted app has no primary environment, so // reading them from there would show "No providers available" everywhere. const projectSettings = useEnvironmentSettings(representative.environmentId); const serverProviders = useAtomValue(serverEnvironment.providersValueAtom(representative.environmentId)) ?? EMPTY_SERVER_PROVIDERS; const updateClientSettings = useUpdateClientSettings(); const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); const threads = useThreadShells(); const updateProject = useAtomCommand(projectEnvironment.update, { reportFailure: false }); const updateServerSettings = useAtomCommand(serverEnvironment.updateSettings, "project setting"); const [savingBrowserAccess, setSavingBrowserAccess] = useState(false); const savingBrowserAccessRef = useRef(false); const browserOverrides = group.memberProjects.map( (member) => environmentById.get(member.environmentId)?.serverConfig?.settings .projectAgentBrowserAccessOverrides[member.id], ); const browserOverride = projectSettings.projectAgentBrowserAccessOverrides[representative.id]; const browserMixed = group.memberProjects.some((member, index) => { const settings = environmentById.get(member.environmentId)?.serverConfig?.settings; if (!settings || !environmentById.get(representative.environmentId)?.serverConfig) return false; return ( browserOverrides[index] !== browserOverride || (browserOverrides[index] ?? settings.enableAgentBrowserAccess) !== (browserOverride ?? projectSettings.enableAgentBrowserAccess) ); }); const setBooleanOverride = async ( key: "projectAgentBrowserAccessOverrides" | "projectAutoPullOverrides", enabled: boolean | undefined, ) => { if (savingBrowserAccessRef.current) return; savingBrowserAccessRef.current = true; setSavingBrowserAccess(true); try { const environmentIds = new Set(group.memberProjects.map((member) => member.environmentId)); for (const environmentId of environmentIds) { const environment = environmentById.get(environmentId); if (!environment?.serverConfig || environment.connection.phase !== "connected") { toastManager.add({ type: "warning", title: "Setting not saved", description: `Connect ${environment?.label ?? "this machine"} and try again.`, }); return; } } if (key === "projectAutoPullOverrides" && enabled === undefined) { const result = await updateAllMembers( { autoPull: false }, "Failed to reset automatic pull", ); if (result._tag === "Failure") return; } for (const environmentId of environmentIds) { const overrides = Object.fromEntries( group.memberProjects .filter((member) => member.environmentId === environmentId) .map((member) => [member.id, enabled ?? null]), ); const result = await updateServerSettings({ environmentId, input: { patch: { [key]: overrides } }, }); if (result._tag === "Failure") { reportFailure( `Failed to save project setting on ${environmentById.get(environmentId)?.label ?? "this machine"}`, mapAtomCommandResult(result, () => undefined), ); return; } } } finally { savingBrowserAccessRef.current = false; setSavingBrowserAccess(false); } }; const setBrowserAccess = (enabled: boolean | undefined) => setBooleanOverride("projectAgentBrowserAccessOverrides", enabled); const deleteProject = useAtomCommand(projectEnvironment.delete, { reportFailure: false }); const projectNameEditedRef = useRef(false); const mergeMethodOverrides = useClientSettings( (settings) => settings.pullRequestMergeMethodOverrides, ); const projectMergeMethod = mergeMethodOverrides[group.projectKey]; const setProjectMergeMethod = (method: PullRequestMergeMethod | null) => { const nextOverrides = { ...mergeMethodOverrides }; if (method === null) { delete nextOverrides[group.projectKey]; } else { nextOverrides[group.projectKey] = method; } updateClientSettings({ pullRequestMergeMethodOverrides: nextOverrides }); }; const faviconPath = representative.faviconPath ?? null; const projectIcon = representative.projectIcon ?? null;